├── .ci_cd ├── README.md ├── env │ ├── .env │ └── .gitignore └── host_init │ └── .gitignore ├── .editorconfig ├── .env ├── .gitattributes ├── .github └── workflows │ ├── ci_cd.yml │ └── publish-release.yml ├── .gitignore ├── .gitmodules ├── .yarn └── releases │ ├── yarn-4.1.0.cjs │ └── yarn-4.3.1.cjs ├── .yarnrc.yml ├── LICENSE.txt ├── README.md ├── build.gradle ├── build_and_publish_to_docker.sh ├── deployment ├── Dockerfile ├── build.gradle ├── keycloak │ └── themes │ │ └── README.md ├── manager │ ├── README.md │ ├── app │ │ ├── images │ │ │ ├── favicon.ico │ │ │ ├── logo-mobile.png │ │ │ └── logo.png │ │ └── manager_config.json │ ├── consoleappconfig │ │ └── .gitignore │ ├── fleet │ │ └── FMC003.json │ └── provisioning │ │ └── .gitignore └── map │ ├── README.md │ └── mapsettings.json ├── docker-compose-test.yml ├── docker-compose.yml ├── gradle.properties ├── gradle └── wrapper │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties ├── gradlew ├── gradlew.bat ├── manager ├── build.gradle └── src │ └── main │ ├── java │ └── telematics │ │ └── teltonika │ │ ├── ITeltonikaPayload.java │ │ ├── TeltonikaConfiguration.java │ │ ├── TeltonikaDataPayload.java │ │ ├── TeltonikaMQTTHandler.java │ │ ├── TeltonikaParameterData.java │ │ ├── TeltonikaPayloadFactory.java │ │ ├── TeltonikaResponsePayload.java │ │ └── helpers │ │ └── TeltonikaConfigurationFactory.java │ └── resources │ └── META-INF │ └── services │ └── org.openremote.manager.mqtt.MQTTHandler ├── model ├── build.gradle └── src │ └── main │ ├── java │ └── org │ │ └── openremote │ │ └── model │ │ ├── custom │ │ ├── AssetStateDuration.java │ │ ├── CarAsset.java │ │ ├── CustomAsset.java │ │ ├── CustomAssetModelProvider.java │ │ ├── CustomData.java │ │ ├── CustomEndpointResource.java │ │ ├── CustomValueTypes.java │ │ └── VehicleAsset.java │ │ └── teltonika │ │ ├── IMEIValidator.java │ │ ├── PayloadJsonObject.java │ │ ├── State.java │ │ ├── TeltonikaConfigurationAsset.java │ │ ├── TeltonikaDataPayloadModel.java │ │ ├── TeltonikaModelConfigurationAsset.java │ │ └── TeltonikaParameter.java │ └── resources │ └── META-INF │ └── services │ └── org.openremote.model.AssetModelProvider ├── package-lock.json ├── package.json ├── profile ├── dev-testing.yml ├── dev-ui.yml └── prod_cicd.yml ├── settings.gradle ├── setup ├── build.gradle └── src │ └── main │ ├── java │ └── org │ │ └── openremote │ │ └── manager │ │ └── setup │ │ └── custom │ │ ├── CustomKeycloakSetup.java │ │ ├── CustomManagerSetup.java │ │ └── CustomSetupTasks.java │ └── resources │ └── META-INF │ └── services │ └── org.openremote.model.setup.SetupTasks ├── test ├── build.gradle └── src │ └── test │ ├── groovy │ └── org │ │ └── openremote │ │ └── test │ │ └── custom │ │ └── TeltonikaMQTTProtocolTest.groovy │ └── resources │ └── teltonika │ ├── SortedPayloads.json │ └── teltonikaValidPayload1.json └── yarn.lock /.ci_cd/README.md: -------------------------------------------------------------------------------- 1 | # CI/CD workflow files 2 | This directory follows the same structure as the main [OpenRemote repo](https://github.com/openremote/openremote/tree/master/.ci_cd). 3 | -------------------------------------------------------------------------------- /.ci_cd/env/.env: -------------------------------------------------------------------------------- 1 | ENV_COMPOSE_FILE=profile/prod_cicd.yml 2 | -------------------------------------------------------------------------------- /.ci_cd/env/.gitignore: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/openremote/fleet-management/68941da21d1b92b35ddf484744affd92658090bf/.ci_cd/env/.gitignore -------------------------------------------------------------------------------- /.ci_cd/host_init/.gitignore: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/openremote/fleet-management/68941da21d1b92b35ddf484744affd92658090bf/.ci_cd/host_init/.gitignore -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | end_of_line = lf 5 | insert_final_newline = true 6 | 7 | [*.{js,json,yml}] 8 | charset = utf-8 9 | indent_style = space 10 | indent_size = 2 11 | -------------------------------------------------------------------------------- /.env: -------------------------------------------------------------------------------- 1 | OR_HOSTNAME=localhost 2 | OR_SSL_PORT=443 3 | KC_HOSTNAME_PORT=-1 4 | WEBSERVER_LISTEN_HOST=0.0.0.0 5 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | /.yarn/** linguist-vendored 2 | /.yarn/releases/* binary 3 | /.yarn/plugins/**/* binary 4 | /.pnp.* binary linguist-generated 5 | -------------------------------------------------------------------------------- /.github/workflows/ci_cd.yml: -------------------------------------------------------------------------------- 1 | # This just references the CI/CD workflow in the main OpenRemote repo 2 | name: CI/CD 3 | 4 | on: 5 | # Push excluding tags and workflow changes 6 | push: 7 | branches: 8 | - '**' 9 | tags-ignore: 10 | - '*.*' 11 | paths-ignore: 12 | - '.github/**' 13 | - '.ci_cd/**' 14 | - '**/*.md' 15 | 16 | # When a release is published 17 | release: 18 | types: [published] 19 | 20 | # Manual trigger 21 | workflow_dispatch: 22 | inputs: 23 | ENVIRONMENT: 24 | description: 'Environment to use (if any)' 25 | MANAGER_TAG: 26 | description: 'Manager docker tag to pull' 27 | CLEAN_INSTALL: 28 | description: 'Delete data before starting' 29 | type: boolean 30 | COMMIT: 31 | description: 'Repo branch or commit SHA to checkout' 32 | 33 | # Un-comment to monitor manager docker image tags for changes and trigger a redeploy when they change (see .ci_cd/README.md) 34 | # schedule: 35 | # - cron: '*/17 * * * *' 36 | 37 | jobs: 38 | call-main-repo: 39 | name: CI/CD 40 | uses: openremote/openremote/.github/workflows/ci_cd.yml@master 41 | with: 42 | INPUTS: ${{ toJSON(github.event.inputs) }} 43 | secrets: 44 | SECRETS: ${{ toJSON(secrets) }} 45 | -------------------------------------------------------------------------------- /.github/workflows/publish-release.yml: -------------------------------------------------------------------------------- 1 | name: Deploy Fleet Management 2 | 3 | on: 4 | workflow_dispatch: 5 | 6 | jobs: 7 | build-and-deploy: 8 | runs-on: ubuntu-latest 9 | steps: 10 | - name: Checkout repository 11 | uses: actions/checkout@v2 12 | 13 | - name: Set up Docker Buildx 14 | uses: docker/setup-buildx-action@v1 15 | 16 | - name: Login to Docker Hub 17 | uses: docker/login-action@v1 18 | with: 19 | username: ${{ secrets.DOCKER_USERNAME }} 20 | password: ${{ secrets.DOCKER_PASSWORD }} 21 | 22 | - name: Get the git deployment version 23 | id: git_version 24 | run: echo "GIT_DEPLOYMENT_VERSION=$(git rev-parse --short HEAD)" >> $GITHUB_ENV 25 | 26 | - name: Set up JDK 11 27 | uses: actions/setup-java@v2 28 | with: 29 | java-version: '17' 30 | distribution: 'adopt' 31 | 32 | - name: Grant execute permission for gradlew 33 | run: chmod +x ./gradlew 34 | 35 | - name: Clean and Install Distribution 36 | run: ./gradlew clean installDist 37 | 38 | - name: Build and push Fleet Deployment Image 39 | uses: docker/build-push-action@v2 40 | with: 41 | context: ./deployment/build/ 42 | file: ./deployment/build/Dockerfile 43 | platforms: linux/amd64,linux/arm64 44 | push: true 45 | tags: | 46 | pankalog/test-deployment:${{ env.GIT_DEPLOYMENT_VERSION }} 47 | pankalog/test-deployment:latest 48 | 49 | - name: Build and push Fleet Management Image 50 | uses: docker/build-push-action@v2 51 | with: 52 | context: ./openremote/manager/build/install/manager/ 53 | file: ./openremote/manager/build/install/manager/Dockerfile 54 | platforms: linux/amd64,linux/arm64 55 | push: true 56 | tags: | 57 | ${{ secrets.DOCKER_USERNAME }}/fleet-test:${{ env.GIT_DEPLOYMENT_VERSION }} 58 | ${{ secrets.DOCKER_USERNAME }}/fleet-test:latest 59 | 60 | - name: Create Release 61 | id: create_release 62 | uses: actions/create-release@v1 63 | env: 64 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 65 | with: 66 | tag_name: ${{ env.GIT_DEPLOYMENT_VERSION }} 67 | release_name: Release ${{ env.GIT_DEPLOYMENT_VERSION }} 68 | body: | 69 | ## Changes 70 | ${{ env.COMMIT_LOG }} 71 | 72 | ## Docker Images 73 | - [Fleet Deployment Image](https://hub.docker.com/r/{{ secrets.DOCKER_USERNAME }}/test-deployment/tags?page=1&name=${{ env.GIT_DEPLOYMENT_VERSION }}) 74 | - [Fleet Management Image](https://hub.docker.com/r/{{ secrets.DOCKER_USERNAME }}/fleet-test/tags?page=1&name=${{ env.GIT_DEPLOYMENT_VERSION }}) -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Build and Release Folders 2 | bin-debug/ 3 | bin-release/ 4 | [Oo]bj/ 5 | [Bb]in/ 6 | [Bb]uild/ 7 | [Oo]ut/ 8 | [Tt]mp/ 9 | 10 | # Other files and folders 11 | .settings/ 12 | .gradle/ 13 | .vagrant/ 14 | .node/ 15 | .classpath/ 16 | .classpath 17 | .project/ 18 | .project 19 | .vscode/ 20 | .idea/ 21 | .sts4-cache/ 22 | .local/ 23 | .DS_*/ 24 | apminsight-javaagent/ 25 | console/iOS/DerivedData 26 | console/iOS/Pods 27 | manager/.factorypath/ 28 | Pods/ 29 | node_modules/ 30 | .pnp.* 31 | .yarn/* 32 | !.yarn/patches 33 | !.yarn/plugins 34 | !.yarn/releases 35 | !.yarn/sdks 36 | !.yarn/versions 37 | 38 | # Specific files and logs 39 | openremote.log 40 | dive.log 41 | yarn-error.log 42 | local.properties 43 | 44 | # File extensions 45 | *.mbtiles 46 | *.air 47 | *.ipa 48 | *.apk 49 | *.dab 50 | *.sh 51 | *.iml 52 | *.ipr 53 | *.iws 54 | *.tsbuildinfo 55 | *~ 56 | *.tar.gz 57 | -------------------------------------------------------------------------------- /.gitmodules: -------------------------------------------------------------------------------- 1 | [submodule "openremote"] 2 | path = openremote 3 | url = https://github.com/openremote/openremote.git 4 | branch = feature/fleet-management 5 | update = rebase 6 | -------------------------------------------------------------------------------- /.yarnrc.yml: -------------------------------------------------------------------------------- 1 | compressionLevel: mixed 2 | 3 | enableGlobalCache: false 4 | 5 | nodeLinker: node-modules 6 | 7 | yarnPath: .yarn/releases/yarn-4.1.0.cjs 8 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # OpenRemote Fleet Management Integration v1 2 | 3 | ![CI/CD](https://github.com/openremote/fleet-management/workflows/CI/CD/badge.svg) 4 | [![Open Source? Yes!](https://badgen.net/badge/Open%20Source%20%3F/Yes%21/blue?icon=github)](https://github.com/Naereen/badges/) 5 | 6 | This repository contains the OpenRemote custom project that contains full support for fleet management features, like 7 | location tracking and session tracking, and also the industry-first **complete, automatic data recognition** from any 8 | Teltonika Telematics device model. 9 | 10 | Please look at the wiki for the tutorial on how to set up your own fleet management system, and the Developer Guide to 11 | understand the inner workings of the fleet management implementation. 12 | 13 | ## Quickstart 14 | 15 | An online demo will be made available to the public shortly, but you can still run the OpenRemote fleet management 16 | implementation locally using Docker: 17 | 18 | The quickest way to get your own environment with full access is to make use of our docker images (both `amd64` and 19 | `arm64` are supported). 20 | 1. Make sure you have [Docker Desktop](https://www.docker.com/products/docker-desktop) installed (v18+). 21 | 2. Download the docker compose file: 22 | [OpenRemote Stack](https://raw.githubusercontent.com/openremote/fleet-management/master/docker-compose.yml) (Right click 'Save link as...') 23 | 3. In a terminal `cd` to where you just saved the compose file and then run: 24 | ``` 25 | docker-compose -p fleet-management up -d 26 | ``` 27 | If all goes well then you should now be able to access the OpenRemote Manager UI at [https://localhost](https://localhost). 28 | You will need to accept the self-signed certificate, see [here](https://www.technipages.com/google-chrome-bypass-your-connection-is-not-private-message) for details how to do this in Chrome 29 | (similar for other browsers). 30 | 31 | To configure the devices and OpenRemote to properly communicate, check the tutorial and quickstart guides in the wiki. 32 | 33 | ### Login credentials 34 | Username: admin 35 | Password: secret 36 | 37 | ### Changing host and/or port 38 | The URL you use to access the system is important, the default is configured as `https://localhost` if you are using a VM or want to run on a different port then you will need to set the `OR_HOSTNAME` and `OR_SSL_PORT` environment variables, so if for example you will be accessing using `https://192.168.1.1:8443` then use the following startup command: 39 | 40 | BASH: 41 | ``` 42 | OR_HOSTNAME=192.168.1.1 OR_SSL_PORT=8443 docker-compose -p fleet-management up -d 43 | ``` 44 | or 45 | 46 | CMD: 47 | ``` 48 | cmd /C "set OR_HOSTNAME=192.168.1.1 && set OR_SSL_PORT=8443 && docker-compose -p fleet-management up -d" 49 | ``` 50 | 51 | 52 | # Custom Project Format 53 | 54 | To create the OpenRemote fleet management integration, a new custom project was created using [OpenRemote's custom-project template](https://github.com/openremote/custom-project). To view the changes of files between the original custom-project repository and the current state of the repository, press [here]( https://github.com/openremote/fleet-management/compare/668ae6fdfb20eeae5977ad62b655bf3fb3d58cdd...main). In this way, you can see the files that have been added since the creation of this repository. 55 | 56 | This repository uses the [feature/fleet-management](https://github.com/openremote/openremote/tree/feature/fleet-management) branch of the main OpenRemote repository as its core, specifically for adding more UI-related features. If the UI features are not something that interest you, you're encouraged to change the submodule to use the `master` OpenRemote branch. 57 | 58 | 59 | # Support and Community 60 | 61 | For support, comments, questions, and concerns, please head to [OpenRemote's forum](https://forum.openremote.io/) and post any questions here. Issues and Pull-Requests created here could be ignored, so if they do, please contact us through the forum. 62 | -------------------------------------------------------------------------------- /build.gradle: -------------------------------------------------------------------------------- 1 | 2 | allprojects { 3 | // Apply common project setup but exclude submodule, it has its own build.gradle 4 | if (!path.startsWith(":openremote")) { 5 | apply from: "${project(":openremote").projectDir}/project.gradle" 6 | } 7 | } 8 | 9 | // Uncomment the following to configure files to be encrypted/decrypted 10 | // Each file must be explicitly added to .gitignore otherwise git commit will fail 11 | // When using encryption the the GFE_PASSWORD environment variable must be set or the build will fail 12 | // use ./gradlew encryptFiles to encrypt files 13 | 14 | //apply plugin: "com.cherryperry.gradle-file-encrypt" 15 | //gradleFileEncrypt { 16 | // // files to encrypt 17 | // plainFiles.from("deployment/manager/fcm.json") 18 | // // (optional) setup file mapping to store all encrypted files in one place for example 19 | // //mapping = [ 'deployment/mySensitiveFile' : 'secrets/mySensitiveFile' ] 20 | // // Use custom password provider as standard env mechanism doesn't seem to work 21 | // passwordProvider = { 22 | // def password = System.env.GFE_PASSWORD 23 | // if (!password) { 24 | // throw new IllegalStateException("GFE_PASSWORD environment variable must be set!") 25 | // } 26 | // return password.toCharArray() 27 | // } 28 | //} 29 | //task checkFilesGitIgnoredNew(type: Exec) { 30 | // // The provided checkFilesGitIgnored task doesn't work on Windows so here's one that does 31 | // def args = [] 32 | // if (org.apache.tools.ant.taskdefs.condition.Os.isFamily(org.apache.tools.ant.taskdefs.condition.Os.FAMILY_WINDOWS)) { 33 | // args.add("cmd") 34 | // args.add("/c") 35 | // } 36 | // args.add("git") 37 | // args.add("check-ignore") 38 | // args.add("-q") 39 | // args.addAll(project.getProperties().get("gradleFileEncrypt").plainFiles) 40 | // 41 | // commandLine args 42 | //} -------------------------------------------------------------------------------- /build_and_publish_to_docker.sh: -------------------------------------------------------------------------------- 1 | 2 | ./gradlew clean installDist 3 | GIT_DEPLOYMENT_VERSION=$(git rev-parse --short HEAD) 4 | docker login 5 | docker buildx create --use --platform=linux/arm64,linux/amd64 --name multi-platform-builder 6 | docker buildx build --load --platform linux/amd64,linux/arm64 -t pankalog/fleet-deployment:"$GIT_DEPLOYMENT_VERSION" --builder multi-platform-builder ./deployment/build/ 7 | docker buildx build --load --platform linux/amd64,linux/arm64 -t pankalog/fleet-management:"$GIT_DEPLOYMENT_VERSION" --builder multi-platform-builder ./openremote/manager/build/install/manager/ 8 | DEPLOYMENT_VERSION="$GIT_DEPLOYMENT_VERSION" docker-compose -p fleet-management up -d 9 | -------------------------------------------------------------------------------- /deployment/Dockerfile: -------------------------------------------------------------------------------- 1 | # ---------------------------------------------------------------------------------------- 2 | # Docker image for populating deployment-data volume with project specific customisations 3 | # ---------------------------------------------------------------------------------------- 4 | FROM alpine:latest 5 | 6 | RUN mkdir -p /deployment/manager/extensions 7 | ADD image /deployment 8 | -------------------------------------------------------------------------------- /deployment/build.gradle: -------------------------------------------------------------------------------- 1 | apply plugin: "java-library" 2 | 3 | dependencies { 4 | api project(":setup") 5 | } 6 | 7 | task license { 8 | doLast { 9 | def toConcatenate = files("${project(":openremote").projectDir}/LICENSE.txt", "${rootDir}/LICENSE.txt") 10 | def outputFileName = "${buildDir}/image/manager/app/LICENSE.txt" 11 | def output = new File(outputFileName) 12 | if (output.exists()) { 13 | output.delete() 14 | } 15 | output.getParentFile().mkdirs() 16 | output.createNewFile() 17 | output.write('') // truncate output if needed 18 | toConcatenate.each { f -> output << f.text } 19 | } 20 | } 21 | 22 | task installDist(type: Copy) { 23 | dependsOn (parent.getTasksByName('installDist', true).findAll { 24 | // Don't create circular dependency or depend on built in openremote submodule apps 25 | it.project != project && !it.project.path.startsWith(":openremote:ui:app") 26 | }) 27 | 28 | into("$buildDir") 29 | 30 | from "Dockerfile" 31 | 32 | into("image") { 33 | from projectDir 34 | exclude "build.gradle", "Dockerfile", "build", "**/*.mbtiles", "src", "**/*.md", ".gitignore" 35 | } 36 | 37 | into("image/manager/extensions") { 38 | from getDeploymentJars() 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /deployment/keycloak/themes/README.md: -------------------------------------------------------------------------------- 1 | ## Custom Keycloak themes 2 | Add a directory for each custom theme (the directory name is the theme name) and add the required theme templates within; you can use the default [openremote theme](https://github.com/openremote/keycloak/tree/main/themes/openremote) as a template alternatively refer to the keycloak themes documentation. 3 | 4 | This themes directory can then be volume mapped into the keycloak container at `/deployment/keycloak/themes`. 5 | 6 | ### Development 7 | To be able to see the custom theme in development (i.e. when using `dev-testing.yml` compose profile) you need to create a copy of `openremote/profile/dev-testing.yml` in the custom project `profile` directory. 8 | -------------------------------------------------------------------------------- /deployment/manager/README.md: -------------------------------------------------------------------------------- 1 | ## Manager customisation 2 | As well as the below information please see the [Manager endpoints and file paths](https://github.com/openremote/openremote/wiki/Architecture:-Manager-endpoints-and-file-paths) wiki. 3 | 4 | ### Custom provisioning files (`provisioning/`) 5 | As an alternative to writing `java` setup code you can also provide `json` representations of Assets which will be automatically deserialized and added to the system when doing a clean install. 6 | 7 | ### Console App Configurations (`consoleappconfig/`) 8 | Console app configurations that can be loaded by Android and iOS consoles. 9 | 10 | ### Custom App Files (`app/`) 11 | This `app` directory is used as the `$CUSTOM_APP_DOCROOT` and can be used to store any custom static content; the Manager UI also checks the `/manager_config.json` path for a custom Manager UI configuration `json` file. 12 | 13 | ### FCM Configuration (`fcm.json`) 14 | This is where your Firebase cloud messaging config file should be placed to enable push notification for Android/iOS. 15 | 16 | ### Logging Configuration (`logging.properties`) 17 | Custom `JUL` logging configuration file; default log file can be found [here](https://github.com/openremote/openremote/blob/master/manager/src/main/resources/logging.properties). 18 | 19 | ### Keycloak Credentials (`keycloak.json`) 20 | This is where custom keycloak credentials are stored/can be supplied; by default the manager will auto generate these during a clean install. 21 | 22 | ### Custom Java Code (`extensions/`) 23 | Any custom java code should be compiled and made available in this directory; if it is compiled as part of the custom project then only the compiled code should be copied to the `deployment/build/image/manager/extensions` dir and not to this source directory. 24 | -------------------------------------------------------------------------------- /deployment/manager/app/images/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/openremote/fleet-management/68941da21d1b92b35ddf484744affd92658090bf/deployment/manager/app/images/favicon.ico -------------------------------------------------------------------------------- /deployment/manager/app/images/logo-mobile.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/openremote/fleet-management/68941da21d1b92b35ddf484744affd92658090bf/deployment/manager/app/images/logo-mobile.png -------------------------------------------------------------------------------- /deployment/manager/app/images/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/openremote/fleet-management/68941da21d1b92b35ddf484744affd92658090bf/deployment/manager/app/images/logo.png -------------------------------------------------------------------------------- /deployment/manager/app/manager_config.json: -------------------------------------------------------------------------------- 1 | { 2 | "loadLocales": true, 3 | "realms": { 4 | "default": { 5 | "appTitle": "Custom Project", 6 | "styles": ":host > * {--or-app-color2: #F9F9F9; --or-app-color3: #22211f; --or-app-color4: #0c4da2; --or-app-color5: #CCCCCC;}", 7 | "logo": "/images/logo.png", 8 | "logoMobile": "/images/logo-mobile.png", 9 | "favicon": "/images/favicon.ico", 10 | "language": "en" 11 | } 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /deployment/manager/consoleappconfig/.gitignore: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/openremote/fleet-management/68941da21d1b92b35ddf484744affd92658090bf/deployment/manager/consoleappconfig/.gitignore -------------------------------------------------------------------------------- /deployment/manager/provisioning/.gitignore: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/openremote/fleet-management/68941da21d1b92b35ddf484744affd92658090bf/deployment/manager/provisioning/.gitignore -------------------------------------------------------------------------------- /deployment/map/README.md: -------------------------------------------------------------------------------- 1 | ## Map customisation 2 | As well as the below information please see the [Manager endpoints and file paths](https://github.com/openremote/openremote/wiki/Architecture:-Manager-endpoints-and-file-paths) wiki. 3 | 4 | The default endpoints and file paths look in the `/deployment/map`, see [here](https://github.com/openremote/openremote/wiki/User-Guide%3A-Custom-deployment#customising-the-map) for more details. 5 | -------------------------------------------------------------------------------- /docker-compose-test.yml: -------------------------------------------------------------------------------- 1 | # OpenRemote v3 2 | # 3 | # Profile for deploying the custom stack; uses deployment-data named volume 4 | # to expose customisations to the manager and keycloak images. To run this profile you need to specify the following 5 | # environment variables: 6 | # 7 | # OR_ADMIN_PASSWORD - Initial admin user password 8 | # OR_HOSTNAME - FQDN hostname of where this instance will be exposed (localhost, IP address or public domain) 9 | # DEPLOYMENT_VERSION - Tag to use for deployment image (must match the tag used when building the deployment image) 10 | # 11 | # Please see openremote/profile/deploy.yml for configuration details for each service. 12 | # 13 | # To perform updates, build code and prepare Docker images: 14 | # 15 | # ./gradlew clean installDist 16 | # 17 | # Then recreate deployment image: 18 | # 19 | # DEPLOYMENT_VERSION=$(git rev-parse --short HEAD) 20 | # MANAGER_VERSION=$(cd openremote; git rev-parse --short HEAD; cd ..) 21 | # docker build -t openremote/manager:$MANAGER_VERSION ./openremote/manager/build/install/manager/ 22 | # docker build -t openremote/custom-deployment:$DEPLOYMENT_VERSION ./deployment/build/ 23 | # docker-compose -p custom down 24 | # docker volume rm custom_deployment-data 25 | # Do the following volume rm command if you want a clean install (wipe all existing data) 26 | # docker volume rm custom_postgresql-data 27 | # OR_ADMIN_PASSWORD=secret OR_HOSTNAME=my.domain.com docker-compose -p custom up -d 28 | # 29 | # All data is kept in volumes. Create a backup of the volumes to preserve data. 30 | # 31 | version: '2.4' 32 | 33 | volumes: 34 | proxy-data: 35 | deployment-data: 36 | postgresql-data: 37 | manager-data: 38 | 39 | # Add an NFS volume to the stack 40 | # efs-data: 41 | # driver: local 42 | # driver_opts: 43 | # type: nfs 44 | # o: "addr=${EFS_DNS?DNS must be set to mount NFS volume},rw,nfsvers=4.1,rsize=1048576,wsize=1048576,hard,timeo=600,retrans=2,noresvport" 45 | # device: ":/" 46 | 47 | 48 | # Re-enable when you figure it out 49 | #x-logging: &awslogs 50 | # logging: 51 | # driver: awslogs 52 | # options: 53 | # awslogs-region: ${AWS_REGION:-eu-west-1} 54 | # awslogs-group: ${OR_HOSTNAME} 55 | # awslogs-create-group: 'true' 56 | # tag: "{{.Name}}/{{.ID}}" 57 | 58 | services: 59 | 60 | # This service will only populate an empty volume on startup and then exit. 61 | # If the volume already contains data, it exits immediately. 62 | deployment: 63 | image: pankalog/fleet-deployment:${DEPLOYMENT_VERSION:-latest} 64 | volumes: 65 | - deployment-data:/deployment 66 | 67 | proxy: 68 | image: openremote/proxy:${PROXY_VERSION:-latest} 69 | restart: always 70 | depends_on: 71 | manager: 72 | condition: service_healthy 73 | ports: 74 | - "80:80" 75 | - "443:443" 76 | - "8883:8883" 77 | volumes: 78 | - proxy-data:/deployment 79 | - deployment-data:/data 80 | environment: 81 | LE_EMAIL: ${OR_EMAIL_ADMIN} 82 | DOMAINNAME: ${OR_HOSTNAME:-localhost} 83 | DOMAINNAMES: ${OR_ADDITIONAL_HOSTNAMES:-} 84 | # USE A CUSTOM PROXY CONFIG - COPY FROM https://github.com/openremote/proxy/blob/main/haproxy.cfg 85 | #HAPROXY_CONFIG: '/data/proxy/haproxy.cfg' 86 | # <<: *awslogs 87 | 88 | postgresql: 89 | image: openremote/postgresql:${POSTGRESQL_VERSION:-latest} 90 | restart: always 91 | volumes: 92 | - postgresql-data:/var/lib/postgresql/data 93 | - manager-data:/storage 94 | # <<: *awslogs 95 | 96 | keycloak: 97 | image: openremote/keycloak:${KEYCLOAK_VERSION:-latest} 98 | restart: always 99 | depends_on: 100 | postgresql: 101 | condition: service_healthy 102 | volumes: 103 | - deployment-data:/deployment 104 | environment: 105 | KEYCLOAK_ADMIN_PASSWORD: ${OR_ADMIN_PASSWORD:-secret} 106 | KC_HOSTNAME: ${OR_HOSTNAME:-localhost} 107 | KC_HOSTNAME_PORT: ${OR_SSL_PORT:--1} 108 | # <<: *awslogs 109 | 110 | manager: 111 | image: pankalog/fleet-management:${MANAGER_VERSION:-latest} 112 | restart: always 113 | depends_on: 114 | keycloak: 115 | condition: service_healthy 116 | volumes: 117 | - manager-data:/storage 118 | - deployment-data:/deployment 119 | # Map data should be accessed from a volume mount 120 | # 1). Host filesystem - /deployment.local:/deployment.local 121 | # 2) NFS/EFS network mount - efs-data:/efs 122 | environment: 123 | # Here are some typical environment variables you want to set 124 | # see openremote/profile/deploy.yml for details 125 | OR_ADMIN_PASSWORD: ${OR_ADMIN_PASSWORD:-secret} 126 | OR_SETUP_TYPE: # Typical values to support are staging and production 127 | OR_SETUP_RUN_ON_RESTART: 128 | OR_EMAIL_HOST: 129 | OR_EMAIL_USER: 130 | OR_EMAIL_PASSWORD: 131 | OR_EMAIL_X_HEADERS: 132 | OR_EMAIL_FROM: 133 | OR_EMAIL_ADMIN: 134 | OR_HOSTNAME: ${OR_HOSTNAME:-localhost} 135 | OR_ADDITIONAL_HOSTNAMES: ${OR_ADDITIONAL_HOSTNAMES:-} 136 | OR_SSL_PORT: ${OR_SSL_PORT:--1} 137 | OR_DEV_MODE: ${OR_DEV_MODE:-false} 138 | OR_MAP_TILES_PATH: '/efs/europe.mbtiles' 139 | #OR_MAP_TILES_PATH: '/efs/europe.mbtiles' 140 | # <<: *awslogs 141 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | # OpenRemote v3 2 | # 3 | # Profile for deploying the custom stack; uses deployment-data named volume 4 | # to expose customisations to the manager and keycloak images. To run this profile you need to specify the following 5 | # environment variables: 6 | # 7 | # OR_ADMIN_PASSWORD - Initial admin user password 8 | # OR_HOSTNAME - FQDN hostname of where this instance will be exposed (localhost, IP address or public domain) 9 | # DEPLOYMENT_VERSION - Tag to use for deployment image (must match the tag used when building the deployment image) 10 | # 11 | # Please see openremote/profile/deploy.yml for configuration details for each service. 12 | # 13 | # To perform updates, build code and prepare Docker images: 14 | # 15 | # ./gradlew clean installDist 16 | # 17 | # Then recreate deployment image: 18 | # 19 | # DEPLOYMENT_VERSION=$(git rev-parse --short HEAD) 20 | # MANAGER_VERSION=$(cd openremote; git rev-parse --short HEAD; cd ..) 21 | # docker build -t openremote/manager:$MANAGER_VERSION ./openremote/manager/build/install/manager/ 22 | # docker build -t openremote/custom-deployment:$DEPLOYMENT_VERSION ./deployment/build/ 23 | # docker-compose -p custom down 24 | # docker volume rm custom_deployment-data 25 | # Do the following volume rm command if you want a clean install (wipe all existing data) 26 | # docker volume rm custom_postgresql-data 27 | # OR_ADMIN_PASSWORD=secret OR_HOSTNAME=my.domain.com docker-compose -p custom up -d 28 | # 29 | # All data is kept in volumes. Create a backup of the volumes to preserve data. 30 | # 31 | version: '2.4' 32 | 33 | volumes: 34 | proxy-data: 35 | deployment-data: 36 | postgresql-data: 37 | manager-data: 38 | 39 | # Add an NFS volume to the stack 40 | # efs-data: 41 | # driver: local 42 | # driver_opts: 43 | # type: nfs 44 | # o: "addr=${EFS_DNS?DNS must be set to mount NFS volume},rw,nfsvers=4.1,rsize=1048576,wsize=1048576,hard,timeo=600,retrans=2,noresvport" 45 | # device: ":/" 46 | 47 | 48 | # Re-enable when you figure it out 49 | #x-logging: &awslogs 50 | # logging: 51 | # driver: awslogs 52 | # options: 53 | # awslogs-region: ${AWS_REGION:-eu-west-1} 54 | # awslogs-group: ${OR_HOSTNAME} 55 | # awslogs-create-group: 'true' 56 | # tag: "{{.Name}}/{{.ID}}" 57 | 58 | services: 59 | 60 | # This service will only populate an empty volume on startup and then exit. 61 | # If the volume already contains data, it exits immediately. 62 | deployment: 63 | image: pankalog/fleet-deployment:${DEPLOYMENT_VERSION:-latest} 64 | volumes: 65 | - deployment-data:/deployment 66 | 67 | proxy: 68 | image: openremote/proxy:${PROXY_VERSION:-latest} 69 | restart: always 70 | depends_on: 71 | manager: 72 | condition: service_healthy 73 | ports: 74 | - "80:80" 75 | - "443:443" 76 | - "8883:8883" 77 | volumes: 78 | - proxy-data:/deployment 79 | - deployment-data:/data 80 | environment: 81 | LE_EMAIL: ${OR_EMAIL_ADMIN} 82 | DOMAINNAME: ${OR_HOSTNAME:-localhost} 83 | DOMAINNAMES: ${OR_ADDITIONAL_HOSTNAMES:-} 84 | # USE A CUSTOM PROXY CONFIG - COPY FROM https://github.com/openremote/proxy/blob/main/haproxy.cfg 85 | #HAPROXY_CONFIG: '/data/proxy/haproxy.cfg' 86 | # <<: *awslogs 87 | 88 | postgresql: 89 | image: openremote/postgresql:${POSTGRESQL_VERSION:-latest} 90 | restart: always 91 | volumes: 92 | - postgresql-data:/var/lib/postgresql/data 93 | - manager-data:/storage 94 | # <<: *awslogs 95 | 96 | keycloak: 97 | image: openremote/keycloak:${KEYCLOAK_VERSION:-latest} 98 | restart: always 99 | depends_on: 100 | postgresql: 101 | condition: service_healthy 102 | volumes: 103 | - deployment-data:/deployment 104 | environment: 105 | KEYCLOAK_ADMIN_PASSWORD: ${OR_ADMIN_PASSWORD:-secret} 106 | KC_HOSTNAME: ${OR_HOSTNAME:-localhost} 107 | KC_HOSTNAME_PORT: ${OR_SSL_PORT:--1} 108 | # <<: *awslogs 109 | 110 | manager: 111 | image: pankalog/fleet-management:${DEPLOYMENT_VERSION:-latest} 112 | restart: always 113 | depends_on: 114 | keycloak: 115 | condition: service_healthy 116 | volumes: 117 | - manager-data:/storage 118 | - deployment-data:/deployment 119 | # Map data should be accessed from a volume mount 120 | # 1). Host filesystem - /deployment.local:/deployment.local 121 | # 2) NFS/EFS network mount - efs-data:/efs 122 | environment: 123 | # Here are some typical environment variables you want to set 124 | # see openremote/profile/deploy.yml for details 125 | OR_ADMIN_PASSWORD: ${OR_ADMIN_PASSWORD:-secret} 126 | OR_SETUP_TYPE: # Typical values to support are staging and production 127 | OR_SETUP_RUN_ON_RESTART: 128 | OR_EMAIL_HOST: 129 | OR_EMAIL_USER: 130 | OR_EMAIL_PASSWORD: 131 | OR_EMAIL_X_HEADERS: 132 | OR_EMAIL_FROM: 133 | OR_EMAIL_ADMIN: 134 | OR_HOSTNAME: ${OR_HOSTNAME:-localhost} 135 | OR_ADDITIONAL_HOSTNAMES: ${OR_ADDITIONAL_HOSTNAMES:-} 136 | OR_SSL_PORT: ${OR_SSL_PORT:--1} 137 | OR_DEV_MODE: ${OR_DEV_MODE:-false} 138 | OR_MAP_TILES_PATH: '/efs/europe.mbtiles' 139 | #OR_MAP_TILES_PATH: '/efs/europe.mbtiles' 140 | # <<: *awslogs 141 | -------------------------------------------------------------------------------- /gradle.properties: -------------------------------------------------------------------------------- 1 | projectName = custom-project 2 | projectVersion = 1.0-SNAPSHOT 3 | typescriptGeneratorVersion = 3.2.1263 4 | org.gradle.parallel=true 5 | -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/openremote/fleet-management/68941da21d1b92b35ddf484744affd92658090bf/gradle/wrapper/gradle-wrapper.jar -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.properties: -------------------------------------------------------------------------------- 1 | distributionBase=GRADLE_USER_HOME 2 | distributionPath=wrapper/dists 3 | distributionUrl=https\://services.gradle.org/distributions/gradle-7.3.1-bin.zip 4 | zipStoreBase=GRADLE_USER_HOME 5 | zipStorePath=wrapper/dists 6 | -------------------------------------------------------------------------------- /gradlew: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | 3 | ############################################################################## 4 | ## 5 | ## Gradle start up script for UN*X 6 | ## 7 | ############################################################################## 8 | 9 | # Attempt to set APP_HOME 10 | # Resolve links: $0 may be a link 11 | PRG="$0" 12 | # Need this for relative symlinks. 13 | while [ -h "$PRG" ] ; do 14 | ls=`ls -ld "$PRG"` 15 | link=`expr "$ls" : '.*-> \(.*\)$'` 16 | if expr "$link" : '/.*' > /dev/null; then 17 | PRG="$link" 18 | else 19 | PRG=`dirname "$PRG"`"/$link" 20 | fi 21 | done 22 | SAVED="`pwd`" 23 | cd "`dirname \"$PRG\"`/" >/dev/null 24 | APP_HOME="`pwd -P`" 25 | cd "$SAVED" >/dev/null 26 | 27 | APP_NAME="Gradle" 28 | APP_BASE_NAME=`basename "$0"` 29 | 30 | # Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 31 | DEFAULT_JVM_OPTS='"-Xmx64m"' 32 | 33 | # Use the maximum available, or set MAX_FD != -1 to use that value. 34 | MAX_FD="maximum" 35 | 36 | warn () { 37 | echo "$*" 38 | } 39 | 40 | die () { 41 | echo 42 | echo "$*" 43 | echo 44 | exit 1 45 | } 46 | 47 | # OS specific support (must be 'true' or 'false'). 48 | cygwin=false 49 | msys=false 50 | darwin=false 51 | nonstop=false 52 | case "`uname`" in 53 | CYGWIN* ) 54 | cygwin=true 55 | ;; 56 | Darwin* ) 57 | darwin=true 58 | ;; 59 | MINGW* ) 60 | msys=true 61 | ;; 62 | NONSTOP* ) 63 | nonstop=true 64 | ;; 65 | esac 66 | 67 | CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar 68 | 69 | # Determine the Java command to use to start the JVM. 70 | if [ -n "$JAVA_HOME" ] ; then 71 | if [ -x "$JAVA_HOME/jre/sh/java" ] ; then 72 | # IBM's JDK on AIX uses strange locations for the executables 73 | JAVACMD="$JAVA_HOME/jre/sh/java" 74 | else 75 | JAVACMD="$JAVA_HOME/bin/java" 76 | fi 77 | if [ ! -x "$JAVACMD" ] ; then 78 | die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME 79 | 80 | Please set the JAVA_HOME variable in your environment to match the 81 | location of your Java installation." 82 | fi 83 | else 84 | JAVACMD="java" 85 | which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 86 | 87 | Please set the JAVA_HOME variable in your environment to match the 88 | location of your Java installation." 89 | fi 90 | 91 | # Increase the maximum file descriptors if we can. 92 | if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then 93 | MAX_FD_LIMIT=`ulimit -H -n` 94 | if [ $? -eq 0 ] ; then 95 | if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then 96 | MAX_FD="$MAX_FD_LIMIT" 97 | fi 98 | ulimit -n $MAX_FD 99 | if [ $? -ne 0 ] ; then 100 | warn "Could not set maximum file descriptor limit: $MAX_FD" 101 | fi 102 | else 103 | warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT" 104 | fi 105 | fi 106 | 107 | # For Darwin, add options to specify how the application appears in the dock 108 | if $darwin; then 109 | GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" 110 | fi 111 | 112 | # For Cygwin, switch paths to Windows format before running java 113 | if $cygwin ; then 114 | APP_HOME=`cygpath --path --mixed "$APP_HOME"` 115 | CLASSPATH=`cygpath --path --mixed "$CLASSPATH"` 116 | JAVACMD=`cygpath --unix "$JAVACMD"` 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 | # Escape application args 158 | save () { 159 | for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done 160 | echo " " 161 | } 162 | APP_ARGS=$(save "$@") 163 | 164 | # Collect all arguments for the java command, following the shell quoting and substitution rules 165 | eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS" 166 | 167 | # by default we should be in the correct project dir, but when run from Finder on Mac, the cwd is wrong 168 | if [ "$(uname)" = "Darwin" ] && [ "$HOME" = "$PWD" ]; then 169 | cd "$(dirname "$0")" 170 | fi 171 | 172 | exec "$JAVACMD" "$@" 173 | -------------------------------------------------------------------------------- /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 | set DIRNAME=%~dp0 12 | if "%DIRNAME%" == "" set DIRNAME=. 13 | set APP_BASE_NAME=%~n0 14 | set APP_HOME=%DIRNAME% 15 | 16 | @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 17 | set DEFAULT_JVM_OPTS="-Xmx64m" 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 Windows variants 50 | 51 | if not "%OS%" == "Windows_NT" goto win9xME_args 52 | 53 | :win9xME_args 54 | @rem Slurp the command line arguments. 55 | set CMD_LINE_ARGS= 56 | set _SKIP=2 57 | 58 | :win9xME_args_slurp 59 | if "x%~1" == "x" goto execute 60 | 61 | set CMD_LINE_ARGS=%* 62 | 63 | :execute 64 | @rem Setup the command line 65 | 66 | set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar 67 | 68 | @rem Execute Gradle 69 | "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %CMD_LINE_ARGS% 70 | 71 | :end 72 | @rem End local scope for the variables with windows NT shell 73 | if "%ERRORLEVEL%"=="0" goto mainEnd 74 | 75 | :fail 76 | rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of 77 | rem the _cmd.exe /c_ return code! 78 | if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 79 | exit /b 1 80 | 81 | :mainEnd 82 | if "%OS%"=="Windows_NT" endlocal 83 | 84 | :omega 85 | -------------------------------------------------------------------------------- /manager/build.gradle: -------------------------------------------------------------------------------- 1 | apply plugin: "java-library" 2 | 3 | dependencies { 4 | api resolveProject(":container") 5 | api resolveProject(":manager") 6 | api project(":model") 7 | } 8 | 9 | task installDist { 10 | dependsOn jar 11 | } 12 | -------------------------------------------------------------------------------- /manager/src/main/java/telematics/teltonika/ITeltonikaPayload.java: -------------------------------------------------------------------------------- 1 | package telematics.teltonika; 2 | 3 | import com.fasterxml.jackson.core.JsonProcessingException; 4 | import org.openremote.container.timer.TimerService; 5 | import org.openremote.model.asset.Asset; 6 | import org.openremote.model.attribute.Attribute; 7 | import org.openremote.model.attribute.AttributeMap; 8 | import org.openremote.model.value.AttributeDescriptor; 9 | 10 | import java.util.Map; 11 | import java.util.logging.Logger; 12 | 13 | public interface ITeltonikaPayload { 14 | 15 | String getModelNumber(); 16 | /** 17 | * Returns list of attributes depending on the Teltonika JSON Payload. 18 | * Uses the logic and results from parsing the Teltonika Parameter IDs. 19 | * 20 | * @return Map of {@link Attribute}s to be assigned to the {@link Asset}. 21 | */ 22 | Map getAttributesFromPayload(TeltonikaConfiguration config, TimerService timerService) throws JsonProcessingException; 23 | 24 | AttributeMap getAttributes(Map payloadMap, TeltonikaConfiguration config, Logger logger, Map> descs); 25 | } 26 | -------------------------------------------------------------------------------- /manager/src/main/java/telematics/teltonika/TeltonikaConfiguration.java: -------------------------------------------------------------------------------- 1 | package telematics.teltonika; 2 | 3 | import org.openremote.manager.asset.AssetStorageService; 4 | import org.openremote.model.attribute.Attribute; 5 | import org.openremote.model.custom.CustomValueTypes; 6 | import org.openremote.model.query.AssetQuery; 7 | import org.openremote.model.query.filter.ParentPredicate; 8 | import org.openremote.model.query.filter.RealmPredicate; 9 | import org.openremote.model.teltonika.TeltonikaConfigurationAsset; 10 | import org.openremote.model.teltonika.TeltonikaModelConfigurationAsset; 11 | 12 | import java.util.Date; 13 | import java.util.HashMap; 14 | import java.util.List; 15 | import java.util.NoSuchElementException; 16 | import java.util.stream.Collectors; 17 | 18 | /** 19 | *

20 | * {@code TeltonikaConfiguration} is a class that holds the configuration for Teltonika devices. 21 | *

22 | *
23 | *

24 | * It employs the Singleton pattern to ensure that only one instance of the class is created. 25 | * We employ the Singleton pattern because retrieving the configuration data is a costly process, which requires a lot 26 | * of requests to the database to retrieve the attributes. 27 | * We want to ensure that we only retrieve the configuration data once and then use it throughout the application. 28 | *

29 | *
30 | *

31 | * The class also employs the Factory pattern in {@code TeltonikaConfigurationFactory} to create the configuration 32 | * object. The factory method is used to create the configuration object and to ensure that only one instance of the 33 | * configuration object is created. 34 | * The factory class does help with managing the instance and creating it, but also helps with keeping it up to date. 35 | * To ensure that the configuration object is up to date, the factory class uses a timestamp to check if the 36 | * configuration has been used for up to a minute. When a minute has elapsed 37 | * (editable using {@code CONFIGURATION_UPDATE_INTERVAL}), it refreshes the instance of the configuration object. 38 | *

39 | *
40 | *

41 | * We want to be sure that the configuration is always up to date, even when the user is configuring the asset. 42 | * To do so, we employ a sufficient-enough version of the observer pattern on the TeltonikaConfigurationFactory. 43 | * When the AttributeEvent handler for configuration asset data detects a change, it triggers a refresh of the 44 | * instance. 45 | *

46 | *
47 | *

48 | * The TeltonikaConfiguration object is also using a builder pattern to make it easier to instantiate and use the 49 | * configuration data. 50 | *

51 | *
52 | *

53 | * A Teltonika configuration consists of multiple assets; one {@code TeltonikaMasterConfigurationAsset} and multiple 54 | * {@code TeltonikaModelConfigurationAssets}. All assets are only retrieved from the {@code master} realm. 55 | * The {@code TeltonikaMasterConfigurationAsset} holds the configuration for the totality of the implementation, with 56 | * settings like payload storage etc., while the {@code TeltonikaModelConfigurationAssets} hold the configuration for 57 | * specific Teltonika device models. They specifically contain the model number and the parameters to be used 58 | * for parsing of the payloads received. 59 | *

60 | */ 61 | public class TeltonikaConfiguration { 62 | 63 | private static TeltonikaConfiguration instance = null; 64 | 65 | public static TeltonikaConfiguration getInstance() throws NullPointerException { 66 | if (instance == null) throw new NullPointerException("TeltonikaConfiguration instance is null"); 67 | //if the update time has elapsed, request update to configuration 68 | if (latestUpdateTimestamp == null || 69 | (new Date().getTime() - latestUpdateTimestamp.getTime()) > CONFIGURATION_UPDATE_INTERVAL 70 | ) { 71 | throw new NullPointerException("TeltonikaConfiguration instance needs to be refreshed."); 72 | } 73 | return instance; 74 | } 75 | 76 | public static void setInstance(TeltonikaConfiguration instance) { 77 | TeltonikaConfiguration.instance = instance; 78 | } 79 | 80 | private static Date latestUpdateTimestamp = null; 81 | 82 | private static final int CONFIGURATION_UPDATE_INTERVAL = 1000 * 60 * 1; // 1 minute 83 | 84 | TeltonikaConfigurationAsset masterAsset; 85 | 86 | public TeltonikaConfigurationAsset getMasterAsset() { 87 | return masterAsset; 88 | } 89 | /** 90 | * Maps Model Number to Map of parameters: {@code [{"FMCOO3": {}...]}} 91 | */ 92 | HashMap defaultParameterMap = new HashMap<>(); 93 | 94 | private List modelAssets; 95 | 96 | 97 | 98 | public TeltonikaConfiguration(TeltonikaConfigurationAsset master, List models, Date date){ 99 | 100 | 101 | if (master == null) return; 102 | if (models.isEmpty()) return; 103 | 104 | masterAsset = master; 105 | 106 | defaultParameterMap = models.stream().collect(Collectors.toMap( 107 | val ->val.getAttributes().get(TeltonikaModelConfigurationAsset.MODEL_NUMBER).get().getValue().get(), // Key Mapper 108 | TeltonikaModelConfigurationAsset::getParameterMap, // Value Mapper 109 | (existing, replacement) -> replacement, // Merge Function 110 | HashMap::new 111 | )); 112 | 113 | modelAssets = models; 114 | latestUpdateTimestamp = date; 115 | 116 | } 117 | 118 | public List getModelAssets() { 119 | return modelAssets; 120 | } 121 | 122 | public TeltonikaModelConfigurationAsset getModelAsset(String modelNumber) throws NoSuchElementException { 123 | return modelAssets.stream() 124 | .filter( 125 | val -> val.getAttributes().get(TeltonikaModelConfigurationAsset.MODEL_NUMBER).get().getValue().get().equals(modelNumber) 126 | ) 127 | .findFirst().orElseThrow(NoSuchElementException::new); 128 | } 129 | 130 | @Override 131 | public String toString() { 132 | return "TeltonikaConfiguration{" + 133 | "masterAsset=" + masterAsset + 134 | ", parameterMap=" + defaultParameterMap + 135 | ", modelAssets=" + modelAssets + 136 | '}'; 137 | } 138 | 139 | public List getChildModelIDs(){ 140 | return modelAssets.stream().map(TeltonikaModelConfigurationAsset::getId).collect(Collectors.toList()); 141 | } 142 | 143 | public Boolean getEnabled(){ 144 | return masterAsset.getAttribute(TeltonikaConfigurationAsset.ENABLED).get().getValue().get(); 145 | } 146 | 147 | public boolean getCheckForImei() { 148 | return masterAsset.getAttribute(TeltonikaConfigurationAsset.CHECK_FOR_IMEI).get().getValue().get(); 149 | } 150 | 151 | public String getDefaultModelNumber() { 152 | return masterAsset.getAttribute(TeltonikaConfigurationAsset.DEFAULT_MODEL_NUMBER).get().getValue().get(); 153 | } 154 | 155 | public HashMap getModelParameterMap(String modelNumber) { 156 | return defaultParameterMap; 157 | } 158 | 159 | public Attribute getCommandAttribute(){ 160 | return getMasterAsset().getAttribute(TeltonikaConfigurationAsset.COMMAND).get(); 161 | } 162 | public Attribute getResponseAttribute(){ 163 | return getMasterAsset().getAttribute(TeltonikaConfigurationAsset.RESPONSE).get(); 164 | } 165 | 166 | public Attribute getStorePayloads(){ 167 | return getMasterAsset().getAttribute(TeltonikaConfigurationAsset.STORE_PAYLOADS).get(); 168 | } 169 | } 170 | -------------------------------------------------------------------------------- /manager/src/main/java/telematics/teltonika/TeltonikaDataPayload.java: -------------------------------------------------------------------------------- 1 | package telematics.teltonika; 2 | 3 | import com.fasterxml.jackson.databind.ObjectMapper; 4 | import org.checkerframework.checker.units.qual.A; 5 | import org.openremote.container.timer.TimerService; 6 | import org.openremote.model.Constants; 7 | import org.openremote.model.asset.Asset; 8 | import org.openremote.model.attribute.Attribute; 9 | import org.openremote.model.attribute.AttributeMap; 10 | import org.openremote.model.attribute.MetaItem; 11 | import org.openremote.model.attribute.MetaMap; 12 | import org.openremote.model.custom.VehicleAsset; 13 | import org.openremote.model.geo.GeoJSONPoint; 14 | import org.openremote.model.teltonika.State; 15 | import org.openremote.model.teltonika.TeltonikaParameter; 16 | import org.openremote.model.util.ValueUtil; 17 | import org.openremote.model.value.*; 18 | 19 | import java.lang.reflect.Field; 20 | import java.sql.Timestamp; 21 | import java.time.Instant; 22 | import java.util.*; 23 | import java.util.logging.Logger; 24 | import java.util.regex.Matcher; 25 | import java.util.regex.Pattern; 26 | import java.util.stream.Collectors; 27 | 28 | import static org.openremote.model.value.MetaItemType.*; 29 | import static org.openremote.model.value.MetaItemType.READ_ONLY; 30 | /** 31 | * This class is used to represent the payload from a Teltonika device when sending a data payload. 32 | * A sample payload can be found and is used in the {@code org.openremote.test.custom.TeltonikaMQTTProtocolTest} class. 33 | *

34 | * It implements the {@code ITeltonikaPayload} interface, which is used to extract the payload's 35 | * attributes and create an attribute map. 36 | */ 37 | public class TeltonikaDataPayload implements ITeltonikaPayload { 38 | 39 | @Override 40 | public String getModelNumber() { 41 | return modelNumber; 42 | } 43 | 44 | private String modelNumber = null; 45 | 46 | protected TeltonikaDataPayload(State payload, String modelNumber) { 47 | this.state = payload; 48 | this.modelNumber = modelNumber; 49 | 50 | } 51 | 52 | private State state; 53 | 54 | public State getState() { 55 | return state; 56 | } 57 | // getter and setter for logger 58 | 59 | private static final Logger logger = Logger.getLogger(TeltonikaDataPayload.class.getName()); 60 | 61 | private Logger getLogger() { 62 | return logger; 63 | } 64 | 65 | /** 66 | * Returns list of attributes depending on the Teltonika JSON Payload. 67 | * Uses the logic and results from parsing the Teltonika Parameter IDs. 68 | */ 69 | public Map getAttributesFromPayload(TeltonikaConfiguration config, TimerService timerService) { 70 | 71 | 72 | HashMap params = new HashMap<>(); 73 | ObjectMapper mapper = new ObjectMapper(); 74 | try { 75 | // Parse file with Parameter details 76 | 77 | // Add each element to the HashMap, with the key being the unique parameter ID and the parameter 78 | // being the value 79 | 80 | //Cast keys to String 81 | params = config.getModelParameterMap(modelNumber).get(config.getDefaultModelNumber()).entrySet().stream().collect(Collectors.toMap( 82 | kvp -> kvp.getKey().toString(), 83 | Map.Entry::getValue, 84 | (existing, replacement) -> existing, 85 | HashMap::new 86 | )); 87 | if (params.size() < 10) { 88 | logger.warning("Parsed " + params.size() + " Teltonika Parameters"); 89 | } 90 | 91 | 92 | } catch (Exception e) { 93 | logger.warning("Could not parse the Teltonika Parameter file"); 94 | logger.info(e.toString()); 95 | } 96 | 97 | // Add the custom parameters (pr, alt, ang, sat, sp, evt) 98 | List customParams = new ArrayList(List.of( 99 | new TeltonikaParameterData("pr", new TeltonikaParameter(-1, "Priority", String.valueOf(1), "Unsigned", String.valueOf(0), String.valueOf(4), String.valueOf(1), "-", "0: Low - 1: High - 2: Panic", "all", "Permanent I/O Elements")), 100 | new TeltonikaParameterData("alt", new TeltonikaParameter(-1, "Altitude", "2", "Signed", "-1000", "+3000", "1", "m", "meters above sea level", "all", "Permanent I/O Elements")), 101 | new TeltonikaParameterData("ang", new TeltonikaParameter(-1, "Direction", "2", "Signed", "-360", "+460", "1", "deg", "degrees from north pole", "all", "Permanent I/O Elements")), 102 | new TeltonikaParameterData("sat", new TeltonikaParameter(-1, "Satellites", "1", "Unsigned", "0", "1000", "1", "-", "number of visible satellites", "all", "Permanent I/O Elements")), 103 | new TeltonikaParameterData("sp", new TeltonikaParameter(-1, "Speed", "2", "Signed", "0", "1000", "1", "km/h", "speed calculated from satellites", "all", "Permanent I/O Elements")), 104 | new TeltonikaParameterData("evt", new TeltonikaParameter(-1, "Event Triggered", "2", "Signed", "0", "10000", "1", "-", "Parameter ID which generated this payload", "all", "Permanent I/O Elements")), 105 | new TeltonikaParameterData("latlng", new TeltonikaParameter(-1, "Coordinates", "8", "ASCII", "-", "-", "-", "-", "The device's coordinates at the given time", "all", "Permanent I/O Elements")), 106 | new TeltonikaParameterData("ts", new TeltonikaParameter(-1, "Timestamp", "8", "Signed", "-", "-", "1", "-", "The device time when the payload was sent", "all", "Permanent I/O Elements")) 107 | )); 108 | 109 | params.putAll(customParams.stream().collect(Collectors.toMap( 110 | TeltonikaParameterData::getParameterId, 111 | TeltonikaParameterData::getParameter, 112 | (existing, replacement) -> existing, 113 | HashMap::new 114 | ))); 115 | //Parameters parsed, time to understand the payload 116 | AttributeMap attributeMap; 117 | 118 | try { 119 | HashMap finalParams = params; 120 | Map payloadMap = this.state.reported.entrySet().stream().collect(Collectors.toMap( 121 | entry -> { 122 | TeltonikaParameterData desiredEntry = null; 123 | 124 | for (Map.Entry parameterEntry : finalParams.entrySet()) { 125 | if (parameterEntry.getKey().equals(entry.getKey())) { 126 | desiredEntry = new TeltonikaParameterData(entry.getKey(), parameterEntry.getValue()); 127 | break; 128 | } 129 | } 130 | 131 | // Check if the entry was found 132 | if (desiredEntry != null) { 133 | return desiredEntry; 134 | } else { 135 | throw new IllegalArgumentException("Key " + entry.getValue().toString() + " not found in finalParams"); 136 | } 137 | }, // Key Mapper 138 | Map.Entry::getValue, // Value Mapper 139 | (existing, replacement) -> { 140 | logger.severe("Parameter " + replacement.toString() + " already exists in the map"); 141 | return null; 142 | }, // Merge Function 143 | HashMap::new 144 | )); 145 | return payloadMap; 146 | } catch (Exception e) { 147 | logger.severe("Failed to payload.state.GetAttributes"); 148 | logger.severe(e.toString()); 149 | throw e; 150 | } 151 | 152 | } 153 | 154 | public AttributeMap getAttributes(Map payloadMap, TeltonikaConfiguration config, Logger logger, Map> descs) { 155 | AttributeMap attributes = new AttributeMap(); 156 | String[] specialProperties = {"latlng", "ts"}; 157 | for (Map.Entry entry : payloadMap.entrySet()) { 158 | 159 | TeltonikaParameter parameter = entry.getKey().getParameter(); 160 | String parameterId = entry.getKey().getParameterId(); 161 | //latlng are the latitude-longitude coordinates, also check if it's 0,0, if it is, don't update. 162 | if (parameterId.equals("latlng") && !Objects.equals(entry.getValue(), "0.000000,0.000000")) { 163 | try { 164 | String latlngString = entry.getValue().toString(); 165 | GeoJSONPoint point = ParseLatLngToGeoJSONObject(latlngString); 166 | Attribute attr = new Attribute<>(Asset.LOCATION, point); 167 | 168 | attributes.add(attr); 169 | } catch (Exception e) { 170 | logger.severe("Failed coordinates"); 171 | logger.severe(e.toString()); 172 | throw e; 173 | } 174 | continue; 175 | } 176 | //Timestamp grabbed from the device. 177 | if (Objects.equals(parameterId, "ts")) { 178 | try { 179 | long unixTimestampMillis = Long.parseLong(entry.getValue().toString()); 180 | Timestamp deviceTimestamp = Timestamp.from(Instant.ofEpochMilli(unixTimestampMillis)); 181 | //Maybe this attribute should have the value set as server time and the device time as a timestamp? 182 | attributes.add(new Attribute<>(VehicleAsset.LAST_CONTACT, deviceTimestamp, deviceTimestamp.getTime())); 183 | 184 | //Update all affected attribute timestamps 185 | attributes.forEach(attribute -> attribute.setTimestamp(deviceTimestamp.getTime())); 186 | } catch (Exception e) { 187 | logger.severe("Failed timestamps"); 188 | logger.severe(e.toString()); 189 | throw e; 190 | } 191 | continue; 192 | } 193 | if(Objects.equals(parameterId, "ang")){ 194 | //This is the parameter ID which triggered the payload 195 | Object angle = ValueUtil.getValueCoerced(entry.getValue(), ValueType.DIRECTION.getType()).orElseThrow(); 196 | Attribute attr = new Attribute(VehicleAsset.DIRECTION, angle); 197 | attributes.add(attr); 198 | continue; 199 | } 200 | 201 | /* 202 | * Quick explanation here, with the new update that allows for custom Asset Types, we need to somehow 203 | * be able to understand when a parameter has been defined as an Attribute in the custom Asset Type that we 204 | * are using. By using the list of AttributeDescriptors, we can check if the parameter ID is present in the 205 | * list of AttributeDescriptors. If it is, then we can directly use the AttributeDescriptor we found to 206 | * dynamically create that attribute in the way the AttributeDescriptor says, whether the Attribute has been 207 | * created yet or not. 208 | * */ 209 | if(descs.containsKey(parameterId)){ 210 | AttributeDescriptor descriptor = descs.get(parameterId); 211 | Object obj = ValueUtil.getValueCoerced(entry.getValue(), descriptor.getType().getType()).orElseThrow(); 212 | Attribute attr = new Attribute(descs.get(parameterId), obj); 213 | attributes.add(attr); 214 | continue; 215 | } 216 | 217 | //Create the MetaItem Map 218 | MetaMap metaMap = new MetaMap(); 219 | 220 | // Figure out its attributeType 221 | ValueDescriptor attributeType = GetAttributeType(parameter); 222 | 223 | //Retrieve its coerced value 224 | Optional value; 225 | try { 226 | //Inner method returns Optional.empty, but still throws and prints exception. A lot of clutter, but the exception is handled. 227 | value = ValueUtil.getValueCoerced(entry.getValue(), attributeType.getType()); 228 | if (value.isEmpty()) { 229 | attributeType = ValueType.TEXT; 230 | value = Optional.of(entry.getValue().toString()); 231 | } 232 | } catch (Exception e) { 233 | value = Optional.of(entry.getValue().toString()); 234 | attributeType = ValueType.TEXT; 235 | logger.severe("Failed value parse"); 236 | logger.severe(e.toString()); 237 | } 238 | Optional originalValue = value; 239 | 240 | double multiplier = 1L; 241 | //If value was parsed correctly, 242 | // Multiply the value with its multiplier if given 243 | if (!Objects.equals(parameter.multiplier, "-")) { 244 | Optional optionalMultiplier = ValueUtil.parse(parameter.multiplier, attributeType.getType()); 245 | 246 | if (optionalMultiplier.isPresent()) { 247 | 248 | if (!ValueUtil.objectsEquals(optionalMultiplier.get(), multiplier)) { 249 | try { 250 | multiplier = (Double) optionalMultiplier.get(); 251 | } catch (Exception e) { 252 | logger.info(e.toString()); 253 | } 254 | } 255 | 256 | try { 257 | double valueNumber = (double) value.get(); 258 | 259 | value = Optional.of(valueNumber * multiplier); 260 | //If the original value is unequal to the new (multiplied) value, then we have to also multiply the constraints 261 | 262 | } catch (Exception e) { 263 | logger.severe(parameterId + "Failed multiplier"); 264 | logger.severe(e.toString()); 265 | throw e; 266 | } 267 | } 268 | 269 | //possibly prepend the unit with the string "custom."? So that it matches the predefined format 270 | //Add on its units 271 | if (!Objects.equals(parameter.units, "-")) { 272 | try { 273 | MetaItem units = new MetaItem<>(MetaItemType.UNITS); 274 | units.setValue(Constants.units(parameter.units)); 275 | // Error when deploying: https://i.imgur.com/4IihWC3.png 276 | // metaMap.add(units); 277 | } catch (Exception e) { 278 | logger.severe(parameterId + "Failed units"); 279 | logger.severe(e.toString()); 280 | throw e; 281 | } 282 | } 283 | } 284 | //Add on its constraints (min, max) 285 | if (ValueUtil.isNumber(attributeType.getType())) { 286 | Optional min; 287 | Optional max; 288 | 289 | try { 290 | //param id 17, 18 and 19, parsed as double, with min = -8000 and max = +8000 is being parsed as 0 and 0? 291 | //You cant do this to me Teltonika, why does parameter ID 237 with constraints (0, 1) have value 2 (and description says it can go up to 99)? 292 | min = ValueUtil.getValueCoerced(parameter.min, attributeType.getBaseType()); 293 | max = ValueUtil.getValueCoerced(parameter.max, attributeType.getBaseType()); 294 | if (min.isPresent() || max.isPresent()) { 295 | MetaItem constraintsMeta = new MetaItem<>(CONSTRAINTS); 296 | List constraintValues = new ArrayList<>(); 297 | //Do I even have to multiply the constraints? 298 | double finalMultiplier = 1; 299 | //Try this with parameter 66 - why is it not properly storing the max constraint? 300 | // It is calculated correctly, check with a debugger, but why is it not storing the data as required? 301 | if (multiplier != 1L) { 302 | finalMultiplier = multiplier; 303 | } 304 | 305 | // Check if the value is correctly within the constraints. If it's not, don't apply the constraint. 306 | // The only reason I am doing this is that the constraints are currently not programmatically, thus "seriously", set. 307 | // If it was properly defined, then parameter ID 237 wouldn't be inaccurate, let alone to this state. 308 | // Not to mention parsing errors (from example from the UDP/Codec 8 to MQTT/Codec JSON converter, look at accelerator axes) 309 | // THE AXES VARS OVERFLOW - if I see that TCT has value -46, if I do 65535 minus the value I am given, then it gives me the real value 310 | if (min.isPresent()) { 311 | if (!((Double) value.get() < (Double) min.get())) { 312 | constraintValues.add(new ValueConstraint.Min((Double) min.get() * finalMultiplier)); 313 | } 314 | } 315 | if (max.isPresent()) { 316 | if (!((Double) value.get() > (Double) max.get())) { 317 | constraintValues.add(new ValueConstraint.Max((Double) max.get() * finalMultiplier)); 318 | } 319 | } 320 | 321 | ValueConstraint[] constraints = constraintValues.toArray(new ValueConstraint[0]); 322 | 323 | constraintsMeta.setValue(constraints); 324 | 325 | //TODO: Fix this, constraints for some reason are not being applied 326 | metaMap.add(constraintsMeta); 327 | } 328 | } catch (Exception e) { 329 | logger.severe(parameterId + "Failed constraints"); 330 | logger.severe(e.toString()); 331 | throw e; 332 | } 333 | } 334 | // Add on its label 335 | try { 336 | MetaItem label = new MetaItem<>(MetaItemType.LABEL); 337 | label.setValue(parameter.propertyName); 338 | metaMap.add(label); 339 | } catch (Exception e) { 340 | logger.severe(parameter.propertyName + "Failed label"); 341 | logger.severe(e.toString()); 342 | throw e; 343 | } 344 | //Use the MetaMap to create an AttributeDescriptor 345 | AttributeDescriptor attributeDescriptor = new AttributeDescriptor<>(parameterId, attributeType, metaMap); 346 | 347 | //Use the AttributeDescriptor and the Value to create a new Attribute 348 | Attribute generatedAttribute = new Attribute(attributeDescriptor, value.get()); 349 | // Add it to the AttributeMap 350 | attributes.addOrReplace(generatedAttribute); 351 | 352 | 353 | } 354 | //Timestamp grabbed from the device. 355 | attributes.get(VehicleAsset.LAST_CONTACT).ifPresent(lastContact -> { 356 | lastContact.getValue().ifPresent(value -> { 357 | attributes.forEach(attribute -> attribute.setTimestamp(value.getTime())); 358 | }); 359 | }); 360 | 361 | // Store data points, allow use for rules, and don't allow user parameter modification, for every attribute parsed 362 | try { 363 | attributes.forEach(attribute -> attribute.addOrReplaceMeta( 364 | new MetaItem<>(STORE_DATA_POINTS, true), 365 | new MetaItem<>(RULE_STATE, true), 366 | new MetaItem<>(READ_ONLY, true) 367 | ) 368 | ); 369 | } catch (Exception e) { 370 | logger.severe("Failed metaItems"); 371 | logger.severe(e.toString()); 372 | throw e; 373 | } 374 | 375 | return attributes; 376 | } 377 | 378 | private static GeoJSONPoint ParseLatLngToGeoJSONObject(String latlngString) { 379 | String regexPattern = "^([-+]?[0-8]?\\d(\\.\\d+)?|90(\\.0+)?),([-+]?(1?[0-7]?[0-9](\\.\\d+)?|180(\\.0+)?))$"; 380 | 381 | Pattern r = Pattern.compile(regexPattern); 382 | Matcher m = r.matcher(latlngString); 383 | 384 | if (m.find()) { 385 | String latitude = m.group(1); 386 | String longitude = m.group(4); 387 | // Since the regex pattern was validated, there is no way for parsing these to throw a NumberFormatException. 388 | 389 | // GeoJSON requires the points in long-lat form, not lat-long 390 | return new GeoJSONPoint(Double.parseDouble(longitude), Double.parseDouble(latitude)); 391 | 392 | } else { 393 | return null; 394 | } 395 | } 396 | 397 | private static ValueDescriptor GetAttributeType(TeltonikaParameter parameter) { 398 | try { 399 | Double.valueOf(parameter.min); 400 | Double.valueOf(parameter.max); 401 | return ValueType.NUMBER; 402 | } catch (NumberFormatException e) { 403 | return switch (parameter.type) { 404 | case "Unsigned", "Signed", "unsigned", "UNSIGNED LONG INT" -> ValueType.NUMBER; 405 | default -> ValueType.TEXT; 406 | }; 407 | } 408 | } 409 | 410 | 411 | } 412 | -------------------------------------------------------------------------------- /manager/src/main/java/telematics/teltonika/TeltonikaMQTTHandler.java: -------------------------------------------------------------------------------- 1 | package telematics.teltonika; 2 | 3 | import com.fasterxml.jackson.core.JsonProcessingException; 4 | import io.netty.buffer.ByteBuf; 5 | import io.netty.handler.codec.mqtt.MqttQoS; 6 | import org.apache.activemq.artemis.spi.core.protocol.RemotingConnection; 7 | import org.keycloak.KeycloakSecurityContext; 8 | import org.openremote.container.timer.TimerService; 9 | import org.openremote.model.util.UniqueIdentifierGenerator; 10 | import org.openremote.manager.asset.AssetProcessingService; 11 | import org.openremote.manager.asset.AssetStorageService; 12 | import org.openremote.manager.datapoint.AssetDatapointService; 13 | import org.openremote.manager.mqtt.MQTTHandler; 14 | import org.openremote.manager.mqtt.Topic; 15 | import org.openremote.manager.security.ManagerIdentityService; 16 | import org.openremote.manager.security.ManagerKeycloakIdentityProvider; 17 | import org.openremote.model.Container; 18 | import org.openremote.model.asset.Asset; 19 | import org.openremote.model.asset.AssetFilter; 20 | import org.openremote.model.asset.AssetTypeInfo; 21 | import org.openremote.model.attribute.*; 22 | import org.openremote.model.custom.*; 23 | import org.openremote.model.datapoint.ValueDatapoint; 24 | import org.openremote.model.query.AssetQuery; 25 | import org.openremote.model.query.filter.*; 26 | import org.openremote.model.syslog.SyslogCategory; 27 | import org.openremote.model.teltonika.*; 28 | import org.openremote.model.util.ValueUtil; 29 | import org.openremote.model.value.*; 30 | import telematics.teltonika.helpers.TeltonikaConfigurationFactory; 31 | 32 | import java.io.IOException; 33 | import java.lang.reflect.Constructor; 34 | import java.lang.reflect.InvocationTargetException; 35 | import java.nio.charset.StandardCharsets; 36 | import java.nio.file.Files; 37 | import java.nio.file.Path; 38 | import java.nio.file.Paths; 39 | import java.sql.Timestamp; 40 | import java.text.MessageFormat; 41 | import java.util.*; 42 | import java.util.concurrent.ConcurrentHashMap; 43 | import java.util.concurrent.ConcurrentMap; 44 | import java.util.logging.Logger; 45 | 46 | import static org.openremote.model.syslog.SyslogCategory.API; 47 | import static org.openremote.model.value.MetaItemType.*; 48 | 49 | public class TeltonikaMQTTHandler extends MQTTHandler { 50 | 51 | public static final Class> TELTONIKA_DEVICE_ASSET_CLASS = CarAsset.class; 52 | public static AssetTypeInfo TELTONIKA_DEVICE_ASSET_INFO = null; 53 | 54 | protected static class TeltonikaDevice { 55 | String clientId; 56 | String commandTopic; 57 | 58 | public TeltonikaDevice(Topic topic) { 59 | this.clientId = topic.getTokens().get(1); 60 | this.commandTopic = String.format("%s/%s/teltonika/%s/commands", 61 | topicRealm(topic), 62 | this.clientId, 63 | topic.getTokens().get(3)); 64 | } 65 | } 66 | 67 | // Ideally, these should be in the Configuration assets, but because I cannot reboot a handler, I cannot change the topics to which the handler handles/subscribes to. 68 | 69 | private static final String TELTONIKA_DEVICE_RECEIVE_TOPIC = "data"; 70 | private static final String TELTONIKA_DEVICE_SEND_TOPIC = "commands"; 71 | private static final String TELTONIKA_DEVICE_TOKEN = "teltonika"; 72 | 73 | private static final Logger LOG = SyslogCategory.getLogger(API, TeltonikaMQTTHandler.class); 74 | 75 | protected AssetStorageService assetStorageService; 76 | protected AssetProcessingService assetProcessingService; 77 | protected AssetDatapointService AssetDatapointService; 78 | protected TimerService timerService; 79 | protected Path DeviceParameterPath; 80 | 81 | protected final ConcurrentMap connectionSubscriberInfoMap = new ConcurrentHashMap<>(); 82 | 83 | private TeltonikaConfiguration config; 84 | private TeltonikaConfiguration getConfig() { 85 | try{ 86 | return TeltonikaConfiguration.getInstance(); 87 | } catch (NullPointerException e) { 88 | TeltonikaConfiguration tempConfig = TeltonikaConfigurationFactory.createConfiguration(assetStorageService, timerService, getParameterFileString()); 89 | TeltonikaConfiguration.setInstance(tempConfig); 90 | config = tempConfig; 91 | } 92 | return config; 93 | } 94 | 95 | /** 96 | * Indicates if this handler will handle the specified topic; independent of whether it is a published message 97 | * or a subscription. 98 | * Should generally check the third token (index 2) onwards unless {@link #handlesTopic} has been overridden. 99 | * 100 | */ 101 | @Override 102 | protected boolean topicMatches(Topic topic) { 103 | return TELTONIKA_DEVICE_TOKEN.equalsIgnoreCase(topicTokenIndexToString(topic, 2)) && getConfig().getEnabled(); 104 | } 105 | 106 | 107 | 108 | @Override 109 | public void start(Container container) throws Exception { 110 | super.start(container); 111 | getLogger().info("Starting Teltonika MQTT Handler"); 112 | ManagerIdentityService identityService = container.getService(ManagerIdentityService.class); 113 | assetStorageService = container.getService(AssetStorageService.class); 114 | assetProcessingService = container.getService(AssetProcessingService.class); 115 | AssetDatapointService = container.getService(AssetDatapointService.class); 116 | timerService = container.getService(TimerService.class); 117 | TELTONIKA_DEVICE_ASSET_INFO = ValueUtil.getAssetInfo(TELTONIKA_DEVICE_ASSET_CLASS).orElseThrow(); 118 | DeviceParameterPath = container.isDevMode() ? Paths.get("../deployment/manager/fleet/FMC003.json") : Paths.get("/deployment/manager/fleet/FMC003.json"); 119 | config = TeltonikaConfigurationFactory.createConfiguration(assetStorageService, timerService, getParameterFileString()); 120 | if (!identityService.isKeycloakEnabled()) { 121 | isKeycloak = false; 122 | } else { 123 | isKeycloak = true; 124 | identityProvider = (ManagerKeycloakIdentityProvider) identityService.getIdentityProvider(); 125 | } 126 | getLogger().warning("Anonymous MQTT connections are allowed, only for the Teltonika Telematics devices, until auto-provisioning is fully implemented or until Teltonika Telematics devices allow user-defined username and password MQTT login."); 127 | 128 | List> assets = assetStorageService.findAll(new AssetQuery().types(TeltonikaModelConfigurationAsset.class)); 129 | 130 | if(assets.isEmpty()) { 131 | getLogger().severe("No Teltonika configuration assets found! Exiting..."); 132 | throw new NullPointerException(); 133 | } 134 | 135 | // Internal Subscription for the command attribute 136 | clientEventService.addInternalSubscription( 137 | AttributeEvent.class, 138 | null, 139 | this::handleAttributeMessage); 140 | 141 | //Internal Subscription for the Asset Configuration 142 | clientEventService.addInternalSubscription( 143 | AttributeEvent.class, 144 | null, 145 | this::handleAssetConfigurationChange 146 | ); 147 | } 148 | 149 | private void handleAssetConfigurationChange(AttributeEvent attributeEvent) { 150 | // throw new NotImplementedException(); 151 | AssetFilter eventFilter = buildConfigurationAssetFilter(); 152 | 153 | if(eventFilter.apply(attributeEvent) == null) return; 154 | 155 | Asset asset = assetStorageService.find(attributeEvent.getRef().getId()); 156 | // if (asset.getType() == ) 157 | 158 | if(Objects.equals(attributeEvent.getName(), TeltonikaModelConfigurationAsset.PARAMETER_MAP.getName())) { 159 | // This means that the parameter map has been updated, hence the new ModelConfigurationAsset is valid. 160 | // Hence, the configuration is now out of date, so we need to update it. 161 | TeltonikaConfigurationFactory.refreshInstance(assetStorageService, timerService, getParameterFileString()); 162 | } 163 | if (Objects.equals(attributeEvent.getName(), TeltonikaModelConfigurationAsset.PARAMETER_DATA.getName())){ 164 | TeltonikaParameter[] newParamList = (TeltonikaParameter[]) attributeEvent.getValue().orElseThrow(); 165 | if(newParamList.length == 0) return; 166 | getLogger().info("Model map configuration event: " + Arrays.toString(newParamList)); 167 | TeltonikaModelConfigurationAsset modelAsset = (TeltonikaModelConfigurationAsset) asset; 168 | modelAsset = modelAsset.setParameterData(newParamList); 169 | AttributeEvent modificationEvent = new AttributeEvent( 170 | asset.getId(), 171 | TeltonikaModelConfigurationAsset.PARAMETER_MAP, 172 | modelAsset.getParameterMap() 173 | ); 174 | // LOG.info("Publishing to client inbound queue: " + attribute.getName()); 175 | assetProcessingService.sendAttributeEvent(modificationEvent); 176 | } 177 | 178 | 179 | } 180 | 181 | /** 182 | * Creates a filter for the AttributeEvents for all attributes of both Teltonika Configuration assets and Teltonika 183 | * Model configuration assets. 184 | * 185 | * @return Attribute filter for all {@code TeltonikaConfigurationAsset} and {@code TeltonikaModelConfigurationAsset}. 186 | */ 187 | private AssetFilter buildConfigurationAssetFilter(){ 188 | 189 | List modelAssets = getConfig().getModelAssets(); 190 | 191 | TeltonikaConfigurationAsset masterAsset = getConfig().getMasterAsset(); 192 | 193 | 194 | List> allIds = new ArrayList<>(modelAssets); 195 | allIds.add(masterAsset); 196 | 197 | AssetFilter event = new AssetFilter<>(); 198 | event.setAssetIds((allIds.stream().map(Asset::getId).toArray(String[]::new))); 199 | return event; 200 | } 201 | 202 | /** 203 | * Creates a filter for the AttributeEvents that could send a command to a Teltonika Device. 204 | * 205 | * @return AssetFilter of VehicleAssets that have both {@code getConfig().getResponseAttribute().getName()} and 206 | * {@code getConfig().getCommandAttribute().getValue().orElse("sendToDevice")} as attributes. 207 | */ 208 | private AssetFilter buildCommandAssetFilter(){ 209 | 210 | List> assetsWithAttribute = assetStorageService 211 | .findAll(new AssetQuery().types(VehicleAsset.class) 212 | .attributeNames(getConfig().getCommandAttribute().getValue().orElse("sendToDevice"))); 213 | List listOfVehicleAssetIds = assetsWithAttribute.stream() 214 | .map(Asset::getId) 215 | .toList(); 216 | 217 | AssetFilter event = new AssetFilter<>(); 218 | event.setAssetIds(listOfVehicleAssetIds.toArray(new String[0])); 219 | event.setAttributeNames(getConfig().getCommandAttribute().getValue().orElse("sendToDevice")); 220 | return event; 221 | } 222 | 223 | private void handleAttributeMessage(AttributeEvent event) { 224 | 225 | AssetFilter eventFilter = buildCommandAssetFilter(); 226 | 227 | if(eventFilter.apply(event) == null) return; 228 | 229 | // If this is not an AttributeEvent that updates a getConfig().getCommandAttribute().getValue().orElse("sendToDevice") field, ignore 230 | if (!Objects.equals(event.getName(), getConfig().getCommandAttribute().getValue().orElse("sendToDevice"))) return; 231 | //Find the asset in question 232 | VehicleAsset asset = assetStorageService.find(event.getId(), VehicleAsset.class); 233 | 234 | // Double check, remove later, sanity checks 235 | if(asset.hasAttribute(getConfig().getCommandAttribute().getValue().orElse("sendToDevice"))){ 236 | if(Objects.equals(event.getId(), asset.getId())){ 237 | 238 | //Get the IMEI of the device 239 | Optional> imei; 240 | String imeiString; 241 | try { 242 | imei = asset.getAttribute(VehicleAsset.IMEI); 243 | if(imei.isEmpty()) throw new Exception(); 244 | if(imei.get().getValue().isEmpty()) throw new Exception(); 245 | imeiString = imei.get().getValue().get(); 246 | 247 | }catch (Exception e){ 248 | getLogger().warning("This Asset does not contain an IMEI! Can't send message!"); 249 | return; 250 | } 251 | 252 | // Get the device subscription information, and even if it's subscribed 253 | TeltonikaDevice deviceInfo = connectionSubscriberInfoMap.get(imeiString); 254 | //If it's null, the device is not subscribed, leave 255 | if(deviceInfo == null) { 256 | getLogger().info(String.format("Device %s is not subscribed to topic, not posting message", 257 | imeiString)); 258 | // throw new Exception("Device is not connected to server"); 259 | // If it is subscribed, check that the attribute's value is not empty, and send the command 260 | } else{ 261 | if(event.getValue().isPresent()){ 262 | sendCommandToTeltonikaDevice((String)event.getValue().get(), deviceInfo); 263 | getLogger().fine("MQTT Message fired"); 264 | } 265 | else{ 266 | getLogger().warning("Attribute "+getConfig().getCommandAttribute().getValue().orElse("sendToDevice")+" was empty"); 267 | } 268 | } 269 | } 270 | } 271 | } 272 | 273 | /** 274 | * Sends a Command to the {@link TeltonikaDevice} in the correct format. 275 | * 276 | * @param command string of the command, without preformatting. 277 | * List of valid commands can be found in Teltonika's website. 278 | * @param device A {@link TeltonikaDevice} that is currently subscribed, to which to send the message to. 279 | */ 280 | private void sendCommandToTeltonikaDevice(String command, TeltonikaDevice device) { 281 | publishMessage(device.commandTopic, Map.of("CMD", command), MqttQoS.EXACTLY_ONCE); 282 | } 283 | 284 | @Override 285 | protected Logger getLogger() { 286 | return LOG; 287 | } 288 | @Override 289 | public boolean checkCanSubscribe(RemotingConnection connection, KeycloakSecurityContext securityContext, Topic topic) { 290 | // Skip standard checks 291 | if (!canSubscribe(connection, securityContext, topic)) { 292 | getLogger().warning("Cannot subscribe to this topic, topic=" + topic + ", connection" + connection); 293 | return false; 294 | } 295 | return true; 296 | } 297 | 298 | /** 299 | * Checks if the Subscribing client should be allowed to subscribe to the topic that is handled by this Handler. 300 | * For Teltonika device endpoints, we need the fourth token (Index 3) to be a valid IMEI number. 301 | * We do that by checking using IMEIValidator. If IMEI checking is false, then skip the check. 302 | */ 303 | // To be removed when auto-provisioning works 304 | @Override 305 | public boolean canSubscribe(RemotingConnection connection, KeycloakSecurityContext securityContext, Topic topic) { 306 | if(topic.getTokens().size() < 5){ 307 | getLogger().warning(MessageFormat.format("Topic {0} is not a valid Topic. Please use a valid Topic.", topic.getString())); 308 | return false; 309 | } 310 | long imeiValue; 311 | try{ 312 | imeiValue = Long.parseLong(topic.getTokens().get(3)); 313 | }catch (NumberFormatException e){ 314 | getLogger().warning(MessageFormat.format("IMEI {0} is not a valid IMEI value. Please use a valid IMEI value.", topic.getTokens().get(3))); 315 | return false; 316 | } 317 | return Objects.equals(topic.getTokens().get(2), TELTONIKA_DEVICE_TOKEN) && 318 | (getConfig().getCheckForImei() ? IMEIValidator.isValidIMEI(imeiValue) : true) && 319 | ( 320 | Objects.equals(topic.getTokens().get(4), TELTONIKA_DEVICE_RECEIVE_TOPIC) || 321 | Objects.equals(topic.getTokens().get(4), TELTONIKA_DEVICE_SEND_TOPIC) 322 | ); 323 | } 324 | 325 | /** 326 | * Overrides MQTTHandler.checkCanPublish for this specific Handler, 327 | * until secure Authentication and Auto-provisioning 328 | * of Teltonika Devices is created. 329 | * To be removed after implementation is complete. 330 | */ 331 | @Override 332 | public boolean checkCanPublish(RemotingConnection connection, KeycloakSecurityContext securityContext, Topic topic) { 333 | return canPublish(connection,securityContext, topic); 334 | } 335 | 336 | @Override 337 | public boolean canPublish(RemotingConnection connection, KeycloakSecurityContext securityContext, Topic topic) { 338 | getLogger().finer("Teltonika device will publish to Topic "+topic.toString()+" to transmit payload"); 339 | return true; 340 | } 341 | 342 | public void onSubscribe(RemotingConnection connection, Topic topic) { 343 | getLogger().info("CONNECT: Device "+topic.getTokens().get(1)+" connected to topic "+topic+"."); 344 | 345 | connectionSubscriberInfoMap.put(topic.getTokens().get(3), new TeltonikaDevice(topic)); 346 | } 347 | 348 | @Override 349 | public void onUnsubscribe(RemotingConnection connection, Topic topic) { 350 | getLogger().info("DISCONNECT: Device "+topic.getTokens().get(1)+" disconnected from topic "+topic+"."); 351 | 352 | connectionSubscriberInfoMap.remove(topic.getTokens().get(3)); 353 | } 354 | 355 | /** 356 | * Get the set of topics this handler wants to subscribe to for incoming publish messages; messages that match 357 | * these topics will be passed to {@link #onPublish}. 358 | * The listener topics are defined as {realmID}/{userID}/{@value TELTONIKA_DEVICE_TOKEN}/{IMEI}/{@value TELTONIKA_DEVICE_RECEIVE_TOPIC} 359 | */ 360 | @Override 361 | public Set getPublishListenerTopics() { 362 | return Set.of( 363 | TOKEN_SINGLE_LEVEL_WILDCARD + "/" + TOKEN_SINGLE_LEVEL_WILDCARD + "/" + 364 | TELTONIKA_DEVICE_TOKEN + "/" + TOKEN_SINGLE_LEVEL_WILDCARD + "/" + TELTONIKA_DEVICE_RECEIVE_TOPIC, 365 | TOKEN_SINGLE_LEVEL_WILDCARD + "/" + TOKEN_SINGLE_LEVEL_WILDCARD + "/" + 366 | TELTONIKA_DEVICE_TOKEN + "/" + TOKEN_SINGLE_LEVEL_WILDCARD + "/" + TELTONIKA_DEVICE_SEND_TOPIC 367 | ); 368 | } 369 | // 370 | // private long startTimestamp = 0; 371 | // private long endTimestamp = 0; 372 | @Override 373 | public void onPublish(RemotingConnection connection, Topic topic, ByteBuf body) { 374 | // startTimestamp = System.currentTimeMillis(); 375 | ITeltonikaPayload payload = null; 376 | String deviceImei = topic.getTokens().get(3); 377 | 378 | String deviceUuid = UniqueIdentifierGenerator.generateId(deviceImei); 379 | 380 | Asset asset = assetStorageService.find(deviceUuid, VehicleAsset.class); 381 | String deviceModelNumber = asset != null 382 | ? asset.getAttribute(VehicleAsset.MODEL_NUMBER).orElseThrow().getValue().orElse(getConfig().getDefaultModelNumber()) 383 | : getConfig().getDefaultModelNumber(); 384 | if (deviceModelNumber == null){ 385 | getLogger().fine("Device Model Number is null, setting to default"); 386 | deviceModelNumber = getConfig().getDefaultModelNumber(); 387 | } 388 | try { 389 | payload = TeltonikaPayloadFactory.getPayload(body.toString(StandardCharsets.UTF_8), deviceModelNumber); 390 | } catch (JsonProcessingException e) { 391 | getLogger().severe(e.toString()); 392 | return; 393 | } 394 | String realm = topic.getTokens().get(0); 395 | String clientId = topic.getTokens().get(1); 396 | 397 | try { 398 | AttributeMap attributes; 399 | try{ 400 | Map data = payload.getAttributesFromPayload(getConfig(), timerService); 401 | attributes = payload.getAttributes(data, getConfig(), getLogger(), TELTONIKA_DEVICE_ASSET_INFO.getAttributeDescriptors()); 402 | }catch (JsonProcessingException e) { 403 | getLogger().severe("Failed to getAttributesFromPayload"); 404 | getLogger().severe(e.toString()); 405 | throw e; 406 | } 407 | 408 | //Create MQTTClientId Attribute 409 | try{ 410 | Attribute clientIdAttribute = new Attribute<>("ClientId", ValueType.TEXT, clientId); 411 | clientIdAttribute.setTimestamp(timerService.getCurrentTimeMillis()); 412 | 413 | attributes.add(clientIdAttribute); 414 | }catch (Exception e){ 415 | getLogger().severe("Failed to create Client ID Attribute"); 416 | } 417 | 418 | 419 | //TODO: If specified in configuration, store payloads (if it WAS a data payload) 420 | try{ 421 | if(getConfig().getStorePayloads().getValue().orElseThrow() && payload instanceof TeltonikaDataPayload){ 422 | Attribute payloadAttribute = new Attribute<>("payload", CustomValueTypes.TELTONIKA_PAYLOAD, new TeltonikaDataPayloadModel(((TeltonikaDataPayload) payload).getState())); 423 | payloadAttribute.addMeta(new MetaItem<>(STORE_DATA_POINTS, true)); 424 | payloadAttribute.setTimestamp(attributes.get(VehicleAsset.LAST_CONTACT).orElseThrow().getValue().orElseThrow().getTime()); 425 | attributes.add(payloadAttribute); 426 | } 427 | }catch (Exception ignored){} 428 | 429 | 430 | 431 | if (asset == null) { 432 | try{ 433 | createNewAsset(deviceUuid, deviceImei, realm, attributes); 434 | } catch (Exception e){ 435 | getLogger().severe("Failed to CreateNewAsset(deviceUuid, deviceImei, realm, attributes);"); 436 | getLogger().severe(e.toString()); 437 | throw e; 438 | } 439 | } 440 | else { 441 | //Check state of Teltonika AVL ID 250 for FMC003, "Trip". 442 | // Optional> sessionAttr = assetChangedTripState(new AttributeRef(asset.getId(), "250")); 443 | // We want the state where the attribute 250 (Trip) is set to true. 444 | 445 | //Any Class that implements `ValuePredicate` can be used here. 446 | AttributePredicate pred = new AttributePredicate("250", new NumberPredicate((double) 1, AssetQuery.Operator.EQUALS)); 447 | 448 | try{ 449 | if (asset.getAttributes().get("250").isEmpty()) { 450 | getLogger().warning("The new value is empty!"); 451 | throw new Exception(); 452 | } else if (attributes.get("250").isEmpty()) { 453 | getLogger().warning("The old value is empty!"); 454 | throw new Exception(); 455 | } 456 | Attribute prevValue = asset.getAttributes().get("250").get(); 457 | Attribute newValue = attributes.get("250").get(); 458 | AttributeRef ref = new AttributeRef(asset.getId(), "250"); 459 | 460 | Optional> sessionAttr = assetChangedTripState(prevValue, newValue, pred.value, ref); 461 | 462 | if (sessionAttr.isPresent()) { 463 | getLogger().warning("New AssetStateDuration"); 464 | Attribute session = sessionAttr.get(); 465 | session.addOrReplaceMeta( 466 | new MetaItem<>(STORE_DATA_POINTS, true), 467 | new MetaItem<>(RULE_STATE, true), 468 | new MetaItem<>(READ_ONLY, true) 469 | ); 470 | // Maybe set this to session.endTime? 471 | attributes.get(VehicleAsset.LAST_CONTACT).flatMap(Attribute::getValue) 472 | .ifPresent(val -> session.setTimestamp(val.getTime())); 473 | attributes.add(session); 474 | 475 | } 476 | }catch (Exception e){ 477 | getLogger().severe("Could not parse Asset State Duration data"); 478 | getLogger().severe(e.toString()); 479 | } 480 | try{ 481 | updateAsset(asset, attributes); 482 | }catch (Exception e){ 483 | getLogger().severe("Failed to UpdateAsset(asset, attributes, topic, connection)"); 484 | getLogger().severe(e.toString()); 485 | throw e; 486 | } 487 | } 488 | 489 | } catch (Exception e){ 490 | getLogger().warning("Could not parse Teltonika device Payload."); 491 | getLogger().warning(e.toString()); 492 | // getLogger().warning(e.fi); 493 | } 494 | // Check if asset was found 495 | } 496 | 497 | /** 498 | * Creates a new asset with the correct "hashed" Asset ID, its IMEI, 499 | * in the realm the MQTT message of the device submitted, 500 | * and the parsed list of attributes. 501 | * @param newDeviceId The ID of the device's Asset. 502 | * @param newDeviceImei The IMEI of the device. If passed to 503 | * {@link UniqueIdentifierGenerator#generateId(String)}, 504 | * it should always return {@code newDeviceId}. 505 | * @param realm The realm to create the Asset in. 506 | * @param attributes The attributes to insert in the Asset. 507 | */ 508 | private void createNewAsset(String newDeviceId, String newDeviceImei, String realm, AttributeMap attributes) { 509 | 510 | Asset newAsset = null; 511 | try { 512 | Constructor> constructor = TELTONIKA_DEVICE_ASSET_CLASS.getConstructor(String.class); 513 | newAsset = constructor.newInstance("Teltonika Asset " + newDeviceImei); 514 | } catch (NoSuchMethodException e) { 515 | getLogger().severe("Constructor for "+ TELTONIKA_DEVICE_ASSET_CLASS.getSimpleName() +" not found with parameter String"); 516 | return; 517 | } catch (InvocationTargetException e) { 518 | getLogger().severe("Constructor for "+ TELTONIKA_DEVICE_ASSET_CLASS.getSimpleName() +" threw an exception"); 519 | return; 520 | } catch (InstantiationException e) { 521 | getLogger().severe("The Class" + TELTONIKA_DEVICE_ASSET_CLASS.getSimpleName() + " is abstract or an interface, and cannot be instantiated."); 522 | return; 523 | } catch (IllegalAccessException e) { 524 | getLogger().severe("The Constructor for " + TELTONIKA_DEVICE_ASSET_CLASS.getSimpleName() + " is not accessible (Could be private, or in general we do not have the needed access modifiers)."); 525 | return; 526 | } 527 | 528 | newAsset = newAsset 529 | .setRealm(realm) 530 | .setModelNumber(getConfig().getDefaultModelNumber()) 531 | .setId(newDeviceId); 532 | 533 | newAsset.getAttribute(VehicleAsset.LOCATION).ifPresentOrElse( 534 | attr -> attr.addMeta(new MetaItem<>(STORE_DATA_POINTS, true)), 535 | () -> getLogger().warning("Couldn't find "+TELTONIKA_DEVICE_ASSET_CLASS.getSimpleName()+".LOCATION") 536 | ); 537 | 538 | newAsset.getAttributes().add(new Attribute<>(VehicleAsset.IMEI, newDeviceImei)); 539 | 540 | // Create Command and Response Attributes 541 | Attribute command = new Attribute<>(new AttributeDescriptor<>(getConfig().getCommandAttribute().getValue().orElse("sendToDevice"), ValueType.TEXT), ""); 542 | newAsset.getAttributes().add(command); 543 | // Attribute response = new Attribute<>(new AttributeDescriptor<>(getConfig().getResponseAttribute().getValue().orElse("sendToDevice"), ValueType.TEXT), ""); 544 | // newAsset.getAttributes().add(response); 545 | 546 | 547 | //Now that the asset is created and IMEI is set, pull the packet timestamp, and then 548 | //set each of the asset's attributes to have that timestamp. 549 | 550 | Asset> finalNewAsset = newAsset; 551 | attributes.get(VehicleAsset.LAST_CONTACT).flatMap(Attribute::getValue).ifPresent(dateVal -> { 552 | finalNewAsset.setCreatedOn(dateVal); 553 | finalNewAsset.getAttributes().forEach(attribute -> attribute.setTimestamp(dateVal.getTime())); 554 | attributes.forEach(attribute -> attribute.setTimestamp(dateVal.getTime())); 555 | }); 556 | 557 | updateAsset(finalNewAsset, attributes); 558 | } 559 | 560 | 561 | 562 | /** 563 | * Returns an {@code Optional>}, that {@code ifPresent()}, represents 564 | * the Duration for which the predicate returned true. 565 | * 566 | * @param previousValue The old attribute state (Or the latest datapoint that exists) 567 | * @param newValue The new attribute value 568 | * @param pred A Predicate that describes the state change 569 | * @param ref An AttributeRef that describes which asset and attribute this pertains to. 570 | * @return An Optional Attribute of type AssetStateDuration that represents the Duration for which the predicate returned true. 571 | */ 572 | private Optional> assetChangedTripState(Attribute previousValue, Attribute newValue, ValuePredicate pred, AttributeRef ref) { 573 | //We will first check if the predicate fails for the new value, and then check if the predicate is true for the previous value. 574 | //In that way, we know that the state change happened between the new and previous values. 575 | 576 | if (newValue.getValue().isEmpty()) { 577 | getLogger().warning("The new value is empty!"); 578 | return Optional.empty(); 579 | } else if (previousValue.getValue().isEmpty()) { 580 | getLogger().warning("The old value is empty!"); 581 | return Optional.empty(); 582 | } 583 | 584 | boolean newValueTest = pred.asPredicate(timerService::getCurrentTimeMillis).test(newValue .getValue().get()); 585 | boolean previousValueTest = pred.asPredicate(timerService::getCurrentTimeMillis).test(previousValue .getValue().get()); 586 | //If the predicate fails, then no changes need to happen. 587 | 588 | // newValue is not 1, previousValue == 1 589 | if(!(!newValueTest && previousValueTest)) { 590 | return Optional.empty(); 591 | } 592 | 593 | // Grab all data-points (To be replaced by AssetDatapointValueQuery) 594 | // For optimization: Maybe pull the data-points from the endTime of the previous AssetStateDuration. 595 | 596 | ArrayList list = new ArrayList<>(AssetDatapointService.getDatapoints(ref)); 597 | 598 | 599 | // If there are no historical data found, add some first 600 | if(list.isEmpty()) { 601 | list.add( 602 | new ValueDatapoint( 603 | timerService.getCurrentTimeMillis(), 604 | new AssetStateDuration( 605 | new Timestamp(previousValue.getTimestamp().get()), 606 | new Timestamp(newValue.getTimestamp().get()) 607 | ) 608 | ) 609 | ); 610 | } 611 | 612 | //What we do now is, we will try to figure out the latest datapoint where the predicate fails, before the newValue. 613 | //This means that, the state change took place between the datapoint we just found and its next one. 614 | 615 | //Find the first datapoint that passes the negated predicate 616 | 617 | ValueDatapoint StateChangeAssetDatapoint = null; 618 | 619 | try { 620 | for (int i = 0; i < list.size()-1; i++) { 621 | // Not using Object.equals, but Datapoint.equals 622 | 623 | ValueDatapoint currentDp = list.get(i); 624 | ValueDatapoint theVeryPreviousDp = list.get(i+1); 625 | 626 | // So, if the currentDp passes the predicate, 627 | boolean currentDpTest = pred.asPredicate(timerService::getCurrentTimeMillis).test(currentDp.getValue()); 628 | // and if the very previous one (NEXT one in the array and PREVIOUS in the time dimension) 629 | // FAILS the predicate, 630 | boolean previousDpTest = pred.asPredicate(timerService::getCurrentTimeMillis).test(theVeryPreviousDp.getValue()); 631 | // A state change happened where the state we are looking for was turned on. 632 | // We want the currentDp. 633 | 634 | 635 | if(currentDpTest && !previousDpTest){ 636 | StateChangeAssetDatapoint = currentDp; 637 | break; 638 | } 639 | } 640 | 641 | if (StateChangeAssetDatapoint != null){ 642 | if (!pred.asPredicate(timerService::getCurrentTimeMillis).test(StateChangeAssetDatapoint.getValue())){ 643 | throw new Exception("Found state change datapoint failed predicate"); 644 | } 645 | }else{ 646 | throw new Exception("Couldn't find asset state change value"); 647 | } 648 | 649 | }catch (Exception e){ 650 | getLogger().warning(e.getMessage()); 651 | return Optional.empty(); 652 | } 653 | 654 | if(previousValue.getTimestamp().isEmpty()){ 655 | getLogger().warning("previousValue's timestamp is empty!"); 656 | return Optional.empty(); 657 | } 658 | 659 | //Because of the way that Teltonika sends the Attribute data, it sometimes sends a 1, then a 0, then a trip for the duration of the real trip. 660 | //Just check if the duration is greater than 10 seconds, any trip less than that should not be recorded. 661 | 662 | if(previousValue.getTimestamp().get() - StateChangeAssetDatapoint.getTimestamp() < 10000) return Optional.empty(); 663 | 664 | Attribute tripAttr = new Attribute<>("LastTripStartedAndEndedAt", CustomValueTypes.ASSET_STATE_DURATION, new AssetStateDuration( 665 | new Timestamp(StateChangeAssetDatapoint.getTimestamp()), 666 | new Timestamp(previousValue.getTimestamp().get()) 667 | )); 668 | 669 | tripAttr.addMeta( 670 | new MetaItem<>(STORE_DATA_POINTS, true), 671 | new MetaItem<>(RULE_STATE, true), 672 | new MetaItem<>(READ_ONLY, true) 673 | ); 674 | 675 | 676 | return Optional.of(tripAttr); 677 | } 678 | 679 | private String getParameterFileString() { 680 | try { 681 | return Files.readString(DeviceParameterPath); 682 | } catch (IOException e) { 683 | getLogger().warning("Couldn't find FMC003.json, couldn't parse parameters"); 684 | throw new RuntimeException(e); 685 | } 686 | } 687 | 688 | /** 689 | * Updates the {@link Asset} passed, with the {@link AttributeMap} passed. 690 | * 691 | * @param asset The asset to be updated. 692 | * @param attributes The attributes to be upserted to the Attribute. 693 | */ 694 | private void updateAsset(Asset> asset, AttributeMap attributes) { 695 | String imei = asset.getAttribute(VehicleAsset.IMEI) 696 | .orElse(new Attribute<>("IMEI", ValueType.TEXT, "Not Found")) 697 | .getValue() 698 | .orElse("Couldn't Find IMEI"); 699 | 700 | getLogger().info("Updating "+ attributes.stream().count() +" attributes of "+TELTONIKA_DEVICE_ASSET_CLASS.getSimpleName()+" with IMEI " + imei + " at Timestamp " + attributes.get(VehicleAsset.LAST_CONTACT)); 701 | 702 | AttributeMap nonExistingAttributes = new AttributeMap(); 703 | AttributeMap existingAttributes = new AttributeMap(); 704 | 705 | attributes.forEach( attribute -> { 706 | if (asset.getAttribute(attribute.getName()).isPresent()) { 707 | existingAttributes.add(attribute); 708 | } else { 709 | nonExistingAttributes.add(attribute); 710 | } 711 | }); 712 | 713 | //First merge, then update existing attributes 714 | asset.addAttributes(nonExistingAttributes.stream().toArray(Attribute[]::new)); 715 | if(!nonExistingAttributes.isEmpty()){ 716 | assetStorageService.merge(asset); 717 | } 718 | 719 | existingAttributes.forEach(attribute -> attribute.getTimestamp().ifPresent(timestamp -> { 720 | AttributeEvent attributeEvent = new AttributeEvent( 721 | asset.getId(), 722 | attribute.getName(), 723 | attribute.getValue().orElseThrow(), 724 | timestamp 725 | ); 726 | // LOG.info("Publishing to client inbound queue: " + attribute.getName()); 727 | assetProcessingService.sendAttributeEvent(attributeEvent); 728 | })); 729 | // endTimestamp = System.currentTimeMillis(); 730 | 731 | // getLogger().info("Updated "+TELTONIKA_DEVICE_ASSET_CLASS.getSimpleName()+" in " + (endTimestamp - startTimestamp) + "ms"); 732 | } 733 | 734 | } 735 | -------------------------------------------------------------------------------- /manager/src/main/java/telematics/teltonika/TeltonikaParameterData.java: -------------------------------------------------------------------------------- 1 | package telematics.teltonika; 2 | 3 | import org.openremote.model.teltonika.TeltonikaParameter; 4 | 5 | import java.util.Map; 6 | 7 | public class TeltonikaParameterData { 8 | String key; 9 | TeltonikaParameter value; 10 | 11 | public TeltonikaParameterData(String key, TeltonikaParameter value) { 12 | this.key = key; 13 | this.value = value; 14 | } 15 | 16 | public String getParameterId() { 17 | return key; 18 | } 19 | 20 | public TeltonikaParameter getParameter() { 21 | return value; 22 | } 23 | 24 | //override equals to compare only keys 25 | @Override 26 | public boolean equals(Object obj) { 27 | if (obj == this) { 28 | return true; 29 | } 30 | if (!(obj instanceof TeltonikaParameterData)) { 31 | return false; 32 | } 33 | return this.key.equals(((TeltonikaParameterData) obj).key); 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /manager/src/main/java/telematics/teltonika/TeltonikaPayloadFactory.java: -------------------------------------------------------------------------------- 1 | package telematics.teltonika; 2 | 3 | 4 | import com.fasterxml.jackson.core.JsonProcessingException; 5 | import com.fasterxml.jackson.databind.JsonNode; 6 | import com.fasterxml.jackson.databind.ObjectMapper; 7 | import jakarta.validation.UnexpectedTypeException; 8 | import org.openremote.model.teltonika.TeltonikaDataPayloadModel; 9 | 10 | public class TeltonikaPayloadFactory { 11 | public static ITeltonikaPayload getPayload(String payload, String modelNumber) throws JsonProcessingException { 12 | ObjectMapper mapper = new ObjectMapper(); 13 | JsonNode rootNode = new ObjectMapper().readTree(payload); 14 | if (rootNode.has("state")) { 15 | // This looks like a DataPayload. 16 | TeltonikaDataPayloadModel model = mapper.readValue(payload, TeltonikaDataPayloadModel.class); 17 | return new TeltonikaDataPayload(model.state, modelNumber); 18 | } else if (rootNode.has("RSP")) { 19 | // This looks like an SMSPayload. 20 | return mapper.readValue(payload, TeltonikaResponsePayload.class); 21 | } else { 22 | throw new UnexpectedTypeException("Unknown type for data payload"); 23 | } 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /manager/src/main/java/telematics/teltonika/TeltonikaResponsePayload.java: -------------------------------------------------------------------------------- 1 | 2 | package telematics.teltonika; 3 | 4 | import com.fasterxml.jackson.annotation.JsonInclude; 5 | import com.fasterxml.jackson.annotation.JsonProperty; 6 | import com.fasterxml.jackson.annotation.JsonPropertyOrder; 7 | import org.openremote.container.timer.TimerService; 8 | import org.openremote.model.attribute.Attribute; 9 | import org.openremote.model.attribute.AttributeMap; 10 | import org.openremote.model.teltonika.TeltonikaParameter; 11 | import org.openremote.model.value.AttributeDescriptor; 12 | 13 | import java.util.Map; 14 | import java.util.logging.Logger; 15 | /** 16 | * This class is used to represent the payload from a Teltonika device when responding to an SMS message. 17 | * It is used to parse the payload and extract the response from the device. 18 | * It arrives in the format of {@code {"RSP":"OK"}}. 19 | *

20 | * It implements the {@code ITeltonikaPayload} interface, which is used to extract the payload's 21 | * attributes and create an attribute map. 22 | */ 23 | @JsonInclude(JsonInclude.Include.NON_NULL) 24 | @JsonPropertyOrder({ 25 | "RSP" 26 | }) 27 | public class TeltonikaResponsePayload implements ITeltonikaPayload { 28 | 29 | @JsonProperty("RSP") 30 | public String rsp; 31 | @Override 32 | public String toString() { 33 | StringBuilder sb = new StringBuilder(); 34 | sb.append(TeltonikaResponsePayload.class.getName()).append('@').append(Integer.toHexString(System.identityHashCode(this))).append('['); 35 | sb.append("rsp"); 36 | sb.append('='); 37 | sb.append(((this.rsp == null)?"":this.rsp)); 38 | sb.append(','); 39 | if (sb.charAt((sb.length()- 1)) == ',') { 40 | sb.setCharAt((sb.length()- 1), ']'); 41 | } else { 42 | sb.append(']'); 43 | } 44 | return sb.toString(); 45 | } 46 | 47 | private String modelNumber; 48 | 49 | @Override 50 | public String getModelNumber() { 51 | return modelNumber; 52 | } 53 | public void setModelNumber(String modelNumber) { 54 | this.modelNumber = modelNumber; 55 | } 56 | 57 | @Override 58 | public Map getAttributesFromPayload(TeltonikaConfiguration config, TimerService timerService) { 59 | TeltonikaParameterData parameter = new TeltonikaParameterData( 60 | "RSP", 61 | //Create fake teltonikaparameter data 62 | new TeltonikaParameter(-1, "RSP", "-", "ASCII", "-", "-", "-", "-", "Response to an SMS message", "0", "0") 63 | ); 64 | return Map.of(parameter, rsp); 65 | } 66 | 67 | public AttributeMap getAttributes(Map payloadMap, TeltonikaConfiguration config, Logger logger, Map> descs) { 68 | AttributeMap attributeMap = new AttributeMap(); 69 | 70 | Attribute attribute = config.getResponseAttribute(); 71 | attribute.setValue((String) payloadMap.get(new TeltonikaParameterData("RSP", null))); 72 | attributeMap.put(attribute); 73 | return attributeMap; 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /manager/src/main/java/telematics/teltonika/helpers/TeltonikaConfigurationFactory.java: -------------------------------------------------------------------------------- 1 | package telematics.teltonika.helpers; 2 | 3 | import com.fasterxml.jackson.core.JsonProcessingException; 4 | import com.fasterxml.jackson.databind.ObjectMapper; 5 | import org.openremote.container.timer.TimerService; 6 | import org.openremote.manager.asset.AssetStorageService; 7 | import org.openremote.model.query.AssetQuery; 8 | import org.openremote.model.query.filter.ParentPredicate; 9 | import org.openremote.model.query.filter.RealmPredicate; 10 | import org.openremote.model.syslog.SyslogCategory; 11 | import org.openremote.model.teltonika.TeltonikaConfigurationAsset; 12 | import org.openremote.model.teltonika.TeltonikaModelConfigurationAsset; 13 | import org.openremote.model.teltonika.TeltonikaParameter; 14 | import telematics.teltonika.TeltonikaConfiguration; 15 | 16 | import java.util.Date; 17 | import java.util.List; 18 | import java.util.logging.Logger; 19 | 20 | import static org.openremote.model.syslog.SyslogCategory.API; 21 | 22 | public class TeltonikaConfigurationFactory { 23 | 24 | private static final Logger LOG = SyslogCategory.getLogger(API, TeltonikaConfigurationFactory.class); 25 | 26 | public static TeltonikaConfiguration createConfiguration(AssetStorageService assetStorageService, TimerService timerService, String fileLocation) { 27 | try{ 28 | return getConfig(assetStorageService, timerService, fileLocation); 29 | } catch (Exception e) { 30 | if(e instanceof IndexOutOfBoundsException) { 31 | LOG.severe("More than 1 Master Teltonika configurations found! Shutting down."); 32 | throw e; 33 | } else if (e instanceof IllegalStateException) { 34 | LOG.severe("No Master Teltonika configuration found! Creating default configuration."); 35 | initializeConfigurationAssets(fileLocation, assetStorageService); 36 | return getConfig(assetStorageService, timerService, fileLocation); 37 | } 38 | throw e; 39 | } 40 | 41 | } 42 | 43 | private static TeltonikaConfiguration getConfig(AssetStorageService assetStorageService, TimerService timerService, String fileLocation) { 44 | List masterAssets = assetStorageService.findAll( 45 | new AssetQuery() 46 | .types(TeltonikaConfigurationAsset.class) 47 | .realm(new RealmPredicate("master")) 48 | 49 | ) 50 | .stream() 51 | .map(asset -> (TeltonikaConfigurationAsset) asset) 52 | .toList(); 53 | 54 | 55 | if (masterAssets.size() > 1) { 56 | throw new IndexOutOfBoundsException("More than 1 Master Teltonika configurations found! Shutting down."); 57 | } 58 | if (masterAssets.isEmpty()) { 59 | throw new IllegalStateException("No Master Teltonika configuration found! You need to create a new default configuration."); 60 | 61 | } 62 | 63 | List modelAssets = assetStorageService.findAll( 64 | new AssetQuery() 65 | .types(TeltonikaModelConfigurationAsset.class) 66 | .realm(new RealmPredicate("master")) 67 | .parents(new ParentPredicate(masterAssets.get(0).getId())) 68 | ) 69 | .stream() 70 | .map(asset -> (TeltonikaModelConfigurationAsset) asset) 71 | .toList(); 72 | 73 | return new TeltonikaConfiguration(masterAssets.get(0), modelAssets, new Date(timerService.getCurrentTimeMillis())); 74 | 75 | } 76 | 77 | private static void initializeConfigurationAssets(String fileLocation, AssetStorageService assetStorageService) { 78 | // Create initial configuration 79 | TeltonikaConfigurationAsset rootConfig = new TeltonikaConfigurationAsset("Teltonika Device Configuration"); 80 | TeltonikaModelConfigurationAsset fmc003 = new TeltonikaModelConfigurationAsset("FMC003"); 81 | 82 | rootConfig.setEnabled(true); 83 | rootConfig.setCheckForImei(false); 84 | rootConfig.setDefaultModelNumber("FMC003"); 85 | rootConfig.setCommandTopic("sendToDevice"); 86 | rootConfig.setResponseTopic("response"); 87 | rootConfig.setStorePayloads(false); 88 | 89 | fmc003.setModelNumber("FMC003"); 90 | ObjectMapper mapper = new ObjectMapper(); 91 | try { 92 | TeltonikaParameter[] params = mapper.readValue(fileLocation, TeltonikaParameter[].class); 93 | fmc003.setParameterData(params); 94 | 95 | } catch (JsonProcessingException e) { 96 | throw new RuntimeException("Could not parse Teltonika Parameter JSON file"); 97 | } 98 | 99 | rootConfig.setRealm("master"); 100 | fmc003.setRealm("master"); 101 | 102 | rootConfig = assetStorageService.merge(rootConfig); 103 | 104 | fmc003.setParent(rootConfig); 105 | 106 | assetStorageService.merge(fmc003); 107 | 108 | } 109 | 110 | public static void refreshInstance(AssetStorageService assetStorageService, TimerService timerService, String fileLocation) { 111 | createConfiguration(assetStorageService, timerService, fileLocation); 112 | } 113 | 114 | } 115 | -------------------------------------------------------------------------------- /manager/src/main/resources/META-INF/services/org.openremote.manager.mqtt.MQTTHandler: -------------------------------------------------------------------------------- 1 | telematics.teltonika.TeltonikaMQTTHandler 2 | -------------------------------------------------------------------------------- /model/build.gradle: -------------------------------------------------------------------------------- 1 | apply plugin: "java-library" 2 | 3 | dependencies { 4 | api resolveProject(":model") 5 | } 6 | 7 | task installDist { 8 | dependsOn jar 9 | } 10 | -------------------------------------------------------------------------------- /model/src/main/java/org/openremote/model/custom/AssetStateDuration.java: -------------------------------------------------------------------------------- 1 | package org.openremote.model.custom; 2 | 3 | import org.openremote.model.asset.Asset; 4 | import org.openremote.model.value.MetaItemType; 5 | 6 | import java.io.Serializable; 7 | import java.sql.Timestamp; 8 | 9 | /** 10 | *

11 | * A {@link org.openremote.model.value.ValueType} that can store 2 and only 2 {@link java.time.LocalDateTime} values, 12 | * that indicate the start-time and the end-time of a time-constrained {@link Asset} state. 13 | * Its use is meant to assist in retrieving historical {@link org.openremote.model.datapoint.Datapoint}s between 14 | * two different {@link java.time.Instant}s. 15 | * 16 | *

17 | * By utilizing a {@link AssetStateDuration} {@link org.openremote.model.attribute.Attribute} 18 | * in conjunction with {@link MetaItemType#STORE_DATA_POINTS}, users can easily request the needed 19 | * {@link AssetStateDuration}s, for any given time duration, e.g. 30 days, with 20 | * which they can then request the specific periods for which an {@link Asset} was in a certain, user-defined, state. 21 | * 22 | *

23 | * As an example, assume a Bike-share service, and each {@link Asset} is a single bike. 24 | * At the end of a fiscal quarter, we would like to analyze the usage of each bicycle, and the time and duration 25 | * at which it moved, to then gauge the profitability of the bike. 26 | * 27 | * Instead of manually retrieving every single datapoint from the asset and then analyzing it to retrieve the value 28 | * changes, we can have an {@link AssetStateDuration} {@link org.openremote.model.attribute.Attribute}, which at 29 | * the end of any given session, or trip, stores the start-time and the end-time of the bike. 30 | * 31 | * As the Attribute has {@link MetaItemType#STORE_DATA_POINTS}, we can retrieve the {@link AssetStateDuration}s 32 | * for the quarter that passed, and for each of the {@link AssetStateDuration}s, request the data-points between 33 | * the start and end Timestamps. 34 | * 35 | * In this way, we have access to every single Duration that the Asset was at any given state. 36 | * 37 | * 38 | * We can then apply any sort of filtering on the set of {@link AssetStateDuration}s to retrieve our needed data. 39 | * 40 | *

41 | */ 42 | public class AssetStateDuration implements Serializable { 43 | private Timestamp startTime; 44 | private Timestamp endTime; 45 | 46 | public AssetStateDuration(Timestamp startTime, Timestamp endTime) { 47 | if (startTime == null || endTime == null) { 48 | throw new IllegalArgumentException("Start time and end time cannot be null"); 49 | } 50 | if (startTime.after(endTime)) { 51 | throw new IllegalArgumentException("Start time cannot be after end time"); 52 | } 53 | this.startTime = startTime; 54 | this.endTime = endTime; 55 | } 56 | 57 | public Timestamp getStartTime() { 58 | return startTime; 59 | } 60 | 61 | public Timestamp getEndTime() { 62 | return endTime; 63 | } 64 | 65 | @Override 66 | public String toString() { 67 | return "AssetStateDuration{" + 68 | "startTime=" + startTime + 69 | ", endTime=" + endTime + 70 | '}'; 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /model/src/main/java/org/openremote/model/custom/CarAsset.java: -------------------------------------------------------------------------------- 1 | package org.openremote.model.custom; 2 | 3 | import jakarta.persistence.Entity; 4 | import org.openremote.model.Constants; 5 | import org.openremote.model.asset.AssetDescriptor; 6 | import org.openremote.model.attribute.MetaItem; 7 | import org.openremote.model.attribute.MetaMap; 8 | import org.openremote.model.teltonika.TeltonikaModelConfigurationAsset; 9 | import org.openremote.model.value.AttributeDescriptor; 10 | import org.openremote.model.value.MetaItemType; 11 | import org.openremote.model.value.ValueType; 12 | import org.openremote.model.value.impl.ColourRGB; 13 | import scala.collection.immutable.Stream; 14 | 15 | import java.util.List; 16 | import java.util.Map; 17 | import java.util.Optional; 18 | /** 19 | * CarAsset is an extension of the VehicleAsset class, specifically intended for the fleet management use case of OpenRemote. 20 | * It is used as the class for the car asset type that should work using this integration. 21 | * 22 | * This Asset Type is used as both an example and as a viable use-case for the OpenRemote Fleet Telematics integration 23 | * of OpenRemote with Teltonika Telematics. 24 | * 25 | * It contains the correct, user-fillable metadata, while also containing some specific attributes that are widely used 26 | * in the fleet management use case. 27 | * 28 | * In this situation, the user has two options; either extend the Vehicle asset as this class, or extend this asset type, 29 | * to use the attributes that are included in it. 30 | * */ 31 | @Entity 32 | public class CarAsset extends VehicleAsset{ 33 | public static final AssetDescriptor DESCRIPTOR = new AssetDescriptor<>("car", null, CarAsset.class); 34 | 35 | // Vehicle meta-data 36 | public static final AttributeDescriptor COLOR = new AttributeDescriptor<>("color", ValueType.COLOUR_RGB).withOptional(true); 37 | public static final AttributeDescriptor MODEL_YEAR = new AttributeDescriptor<>("modelYear", ValueType.INTEGER).withOptional(true) 38 | .withUnits(Constants.UNITS_YEAR); 39 | public static final AttributeDescriptor LICENSE_PLATE = new AttributeDescriptor<>("licensePlate", ValueType.TEXT).withOptional(true); 40 | 41 | //Ignition 42 | public static final AttributeDescriptor IGNITION_ON = new AttributeDescriptor<>("239", ValueType.BOOLEAN) 43 | .withMeta(TeltonikaModelConfigurationAsset.getPayloadAttributeMeta("Ignition status")); 44 | 45 | //Movement 46 | public static final AttributeDescriptor MOVEMENT = new AttributeDescriptor<>("240", ValueType.BOOLEAN) 47 | .withMeta(TeltonikaModelConfigurationAsset.getPayloadAttributeMeta("Movement status")); 48 | 49 | //odometer 50 | public static final AttributeDescriptor ODOMETER = new AttributeDescriptor<>("16", ValueType.NUMBER) 51 | .withMeta(TeltonikaModelConfigurationAsset.getPayloadAttributeMeta("Odometer")) 52 | .withUnits(Constants.UNITS_METRE); 53 | 54 | 55 | // All the permanent ones (pr, alt, ang, sat, sp, evt) 56 | 57 | public static final AttributeDescriptor EVENT_ATTR_NAME = new AttributeDescriptor<>("evt", ValueType.NUMBER).withOptional(true) 58 | .withMeta(TeltonikaModelConfigurationAsset.getPayloadAttributeMeta("Event triggered by")); 59 | public static final AttributeDescriptor ALTITUDE = new AttributeDescriptor<>("alt", ValueType.NUMBER).withOptional(true) 60 | .withMeta(TeltonikaModelConfigurationAsset.getPayloadAttributeMeta("Altitude")) 61 | .withUnits(Constants.UNITS_METRE); 62 | public static final AttributeDescriptor SATELLITES = new AttributeDescriptor<>("sat", ValueType.NUMBER).withOptional(true) 63 | .withMeta(TeltonikaModelConfigurationAsset.getPayloadAttributeMeta("Number of satellites in use")); 64 | public static final AttributeDescriptor SPEED = new AttributeDescriptor<>("sp", ValueType.NUMBER) 65 | .withMeta(TeltonikaModelConfigurationAsset.getPayloadAttributeMeta("Speed")) 66 | .withUnits(Constants.UNITS_KILO, Constants.UNITS_METRE, Constants.UNITS_PER, Constants.UNITS_HOUR); 67 | public static final AttributeDescriptor PRIORITY = new AttributeDescriptor<>("pr", ValueType.NUMBER).withOptional(true) 68 | .withMeta(TeltonikaModelConfigurationAsset.getPayloadAttributeMeta("Payload priority (0-2)")); 69 | 70 | 71 | 72 | //Hydration 73 | protected CarAsset() { 74 | } 75 | 76 | public CarAsset(String name) { 77 | super(name); 78 | } 79 | public Optional getModelYear() { 80 | return getAttributes().getValue(MODEL_YEAR); 81 | } 82 | public Optional getColor() { 83 | return getAttributes().getValue(COLOR); 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /model/src/main/java/org/openremote/model/custom/CustomAsset.java: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2021, OpenRemote Inc. 3 | * 4 | * See the CONTRIBUTORS.txt file in the distribution for a 5 | * full listing of individual contributors. 6 | * 7 | * This program is free software: you can redistribute it and/or modify 8 | * it under the terms of the GNU Affero General Public License as 9 | * published by the Free Software Foundation, either version 3 of the 10 | * License, or (at your option) any later version. 11 | * 12 | * This program is distributed in the hope that it will be useful, 13 | * but WITHOUT ANY WARRANTY; without even the implied warranty of 14 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 15 | * GNU Affero General Public License for more details. 16 | * 17 | * You should have received a copy of the GNU Affero General Public License 18 | * along with this program. If not, see . 19 | */ 20 | package org.openremote.model.custom; 21 | 22 | import org.openremote.model.asset.Asset; 23 | import org.openremote.model.asset.AssetDescriptor; 24 | import org.openremote.model.value.AttributeDescriptor; 25 | import org.openremote.model.value.ValueDescriptor; 26 | 27 | import jakarta.persistence.Entity; 28 | import java.util.Optional; 29 | 30 | /** 31 | * This is an example of a custom {@link Asset} type; this must be registered via an 32 | * {@link org.openremote.model.AssetModelProvider} and must conform to the following requirements: 33 | * 34 | *
    35 | *
  • Must have {@link Entity} annotation 36 | *
  • Optionally add {@link org.openremote.model.value.ValueDescriptor}s 37 | *
  • Optionally add {@link org.openremote.model.value.MetaItemDescriptor}s 38 | *
  • Optionally add {@link org.openremote.model.value.AttributeDescriptor}s 39 | *
  • Must have a public static final {@link org.openremote.model.asset.AssetDescriptor} 40 | *
  • Must have a protected no args constructor (for hydrators i.e. JPA/Jackson) 41 | *
  • For a given {@link Asset} type only one {@link org.openremote.model.asset.AssetDescriptor} can exist 42 | *
  • {@link org.openremote.model.value.AttributeDescriptor}s that override a super class descriptor cannot change the 43 | * value type; just the formatting etc. 44 | *
  • {@link org.openremote.model.value.MetaItemDescriptor}s names must be unique 45 | *
  • {@link org.openremote.model.value.ValueDescriptor}s names must be unique 46 | *
47 | */ 48 | @Entity 49 | public class CustomAsset extends Asset { 50 | 51 | public enum CustomValueType { 52 | ONE, 53 | TWO, 54 | THREE 55 | } 56 | 57 | public static final ValueDescriptor CUSTOM_VALUE_TYPE_VALUE_DESCRIPTOR = new ValueDescriptor<>("customValueType", CustomValueType.class); 58 | 59 | public static final AttributeDescriptor CUSTOM_VALUE_TYPE_ATTRIBUTE_DESCRIPTOR = new AttributeDescriptor<>("customAttribute", CUSTOM_VALUE_TYPE_VALUE_DESCRIPTOR); 60 | 61 | public static final AssetDescriptor CUSTOM_ASSET_ASSET_DESCRIPTOR = new AssetDescriptor<>("brightness-auto", "00aaaa", CustomAsset.class); 62 | 63 | public Optional getCustomAttribute() { 64 | return getAttributes().getValue(CUSTOM_VALUE_TYPE_ATTRIBUTE_DESCRIPTOR); 65 | } 66 | 67 | public CustomAsset setCustomAttribute(CustomValueType value) { 68 | getAttributes().getOrCreate(CUSTOM_VALUE_TYPE_ATTRIBUTE_DESCRIPTOR).setValue(value); 69 | return this; 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /model/src/main/java/org/openremote/model/custom/CustomAssetModelProvider.java: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2021, OpenRemote Inc. 3 | * 4 | * See the CONTRIBUTORS.txt file in the distribution for a 5 | * full listing of individual contributors. 6 | * 7 | * This program is free software: you can redistribute it and/or modify 8 | * it under the terms of the GNU Affero General Public License as 9 | * published by the Free Software Foundation, either version 3 of the 10 | * License, or (at your option) any later version. 11 | * 12 | * This program is distributed in the hope that it will be useful, 13 | * but WITHOUT ANY WARRANTY; without even the implied warranty of 14 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 15 | * GNU Affero General Public License for more details. 16 | * 17 | * You should have received a copy of the GNU Affero General Public License 18 | * along with this program. If not, see . 19 | */ 20 | package org.openremote.model.custom; 21 | 22 | import org.openremote.model.AssetModelProvider; 23 | import org.openremote.model.ModelDescriptor; 24 | import org.openremote.model.ModelDescriptors; 25 | import org.openremote.model.asset.Asset; 26 | 27 | @ModelDescriptors({@ModelDescriptor(assetType = Asset.class, provider = CustomValueTypes.class)}) 28 | public class CustomAssetModelProvider implements AssetModelProvider { 29 | 30 | @Override 31 | public boolean useAutoScan() { 32 | return true; 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /model/src/main/java/org/openremote/model/custom/CustomData.java: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2022, OpenRemote Inc. 3 | * 4 | * See the CONTRIBUTORS.txt file in the distribution for a 5 | * full listing of individual contributors. 6 | * 7 | * This program is free software: you can redistribute it and/or modify 8 | * it under the terms of the GNU Affero General Public License as 9 | * published by the Free Software Foundation, either version 3 of the 10 | * License, or (at your option) any later version. 11 | * 12 | * This program is distributed in the hope that it will be useful, 13 | * but WITHOUT ANY WARRANTY; without even the implied warranty of 14 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 15 | * GNU Affero General Public License for more details. 16 | * 17 | * You should have received a copy of the GNU Affero General Public License 18 | * along with this program. If not, see . 19 | */ 20 | package org.openremote.model.custom; 21 | 22 | public class CustomData { 23 | 24 | protected String name; 25 | protected Integer age; 26 | 27 | protected CustomData() { 28 | } 29 | 30 | public String getName() { 31 | return name; 32 | } 33 | 34 | public Integer getAge() { 35 | return age; 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /model/src/main/java/org/openremote/model/custom/CustomEndpointResource.java: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2022, OpenRemote Inc. 3 | * 4 | * See the CONTRIBUTORS.txt file in the distribution for a 5 | * full listing of individual contributors. 6 | * 7 | * This program is free software: you can redistribute it and/or modify 8 | * it under the terms of the GNU Affero General Public License as 9 | * published by the Free Software Foundation, either version 3 of the 10 | * License, or (at your option) any later version. 11 | * 12 | * This program is distributed in the hope that it will be useful, 13 | * but WITHOUT ANY WARRANTY; without even the implied warranty of 14 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 15 | * GNU Affero General Public License for more details. 16 | * 17 | * You should have received a copy of the GNU Affero General Public License 18 | * along with this program. If not, see . 19 | */ 20 | package org.openremote.model.custom; 21 | 22 | import jakarta.ws.rs.POST; 23 | import jakarta.ws.rs.Path; 24 | 25 | /** 26 | * This is an example custom JAX-RS endpoint; this will be compiled and made available in the typescript model as well 27 | */ 28 | @Path("custom") 29 | public interface CustomEndpointResource { 30 | 31 | @POST 32 | void submitData(CustomData customData); 33 | } 34 | -------------------------------------------------------------------------------- /model/src/main/java/org/openremote/model/custom/CustomValueTypes.java: -------------------------------------------------------------------------------- 1 | package org.openremote.model.custom; 2 | 3 | import org.openremote.model.teltonika.TeltonikaDataPayloadModel; 4 | import org.openremote.model.teltonika.TeltonikaParameter; 5 | import org.openremote.model.value.ValueDescriptor; 6 | 7 | import java.util.HashMap; 8 | 9 | public class CustomValueTypes { 10 | public static final ValueDescriptor ASSET_STATE_DURATION = new ValueDescriptor<>("AssetStateDuration", AssetStateDuration.class); 11 | public static final ValueDescriptor TELTONIKA_PARAMETER = new ValueDescriptor<>("TeltonikaParameter", TeltonikaParameter.class); 12 | public static class TeltonikaParameterMap extends HashMap {} 13 | 14 | public static final ValueDescriptor TELTONIKA_PARAMETER_MAP = new ValueDescriptor<>("TeltonikaParameterMap", TeltonikaParameterMap.class); 15 | 16 | public static final ValueDescriptor TELTONIKA_PAYLOAD = new ValueDescriptor<>("TeltonikaPayload", TeltonikaDataPayloadModel.class); 17 | } 18 | -------------------------------------------------------------------------------- /model/src/main/java/org/openremote/model/custom/VehicleAsset.java: -------------------------------------------------------------------------------- 1 | package org.openremote.model.custom; 2 | 3 | import jakarta.persistence.Entity; 4 | import org.openremote.model.Constants; 5 | import org.openremote.model.asset.Asset; 6 | import org.openremote.model.asset.AssetDescriptor; 7 | import org.openremote.model.attribute.MetaMap; 8 | import org.openremote.model.geo.GeoJSONPoint; 9 | import org.openremote.model.teltonika.TeltonikaModelConfigurationAsset; 10 | import org.openremote.model.value.AttributeDescriptor; 11 | import org.openremote.model.value.ValueType; 12 | import org.openremote.model.value.impl.ColourRGB; 13 | 14 | import java.util.Date; 15 | import java.util.Optional; 16 | /** 17 | * {@code VehicleAsset} is a custom asset type specifically intended for the fleet management use case of OpenRemote. 18 | * It is used as the base class of any subsequent vehicle asset types that should work using this integration. 19 | * The VehicleAsset class contains all required attributes and methods to be used by the Teltonika Telematics integration. 20 | * 21 | * In case the user wants to add more attributes to the vehicle asset, they can do so by extending the VehicleAsset class. 22 | * To view such an example, see the CarAsset class. 23 | */ 24 | @Entity 25 | public class VehicleAsset extends Asset { 26 | 27 | public static final AttributeDescriptor LOCATION = new AttributeDescriptor<>("location", ValueType.GEO_JSON_POINT) 28 | .withMeta(TeltonikaModelConfigurationAsset.getPayloadAttributeMeta("Location")); 29 | 30 | public static final AttributeDescriptor IMEI = new AttributeDescriptor<>("IMEI", ValueType.TEXT); 31 | public static final AttributeDescriptor LAST_CONTACT = new AttributeDescriptor<>("lastContact", ValueType.DATE_AND_TIME) 32 | .withMeta(TeltonikaModelConfigurationAsset.getPayloadAttributeMeta("Last message time")); 33 | public static final AttributeDescriptor MODEL_NUMBER = new AttributeDescriptor<>("modelNumber", ValueType.TEXT); 34 | 35 | public static final AttributeDescriptor DIRECTION = new AttributeDescriptor<>("direction", ValueType.DIRECTION) 36 | .withMeta(TeltonikaModelConfigurationAsset.getPayloadAttributeMeta("Direction")) 37 | .withUnits(Constants.UNITS_DEGREE); 38 | 39 | // Figure out a way to use the colour parameter for the color of the car on the map 40 | 41 | 42 | 43 | public static final AssetDescriptor DESCRIPTOR = new AssetDescriptor<>("car", null, VehicleAsset.class); 44 | 45 | protected VehicleAsset(){ 46 | } 47 | public VehicleAsset(String name){super(name);} 48 | 49 | public Optional getIMEI() { 50 | return getAttributes().getValue(IMEI); 51 | } 52 | public Optional getLastContact() { 53 | return getAttributes().getValue(LAST_CONTACT); 54 | } 55 | 56 | public Optional getModelNumber(){return getAttributes().getValue(MODEL_NUMBER);} 57 | 58 | public VehicleAsset setModelNumber(String value){ 59 | getAttributes().getOrCreate(MODEL_NUMBER).setValue(value); 60 | return this; 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /model/src/main/java/org/openremote/model/teltonika/IMEIValidator.java: -------------------------------------------------------------------------------- 1 | package org.openremote.model.teltonika; 2 | 3 | /** 4 | * Class that validates an IMEI value. 5 | * Employs checksum as outlined by the GSM Association. 6 | * Class retrieved from GeeksForGeeks. 7 | * 8 | * @see the GSMA IMEI Allocation Guidelines. 9 | * 10 | */ 11 | public class IMEIValidator { 12 | // Function for finding and returning 13 | // sum of digits of a number 14 | static int sumDig(int n) { 15 | int a = 0; 16 | while (n > 0) { 17 | a = a + n % 10; 18 | n = n / 10; 19 | } 20 | return a; 21 | } 22 | 23 | public static boolean isValidIMEI(long n) { 24 | // Converting the number into String 25 | // for finding length 26 | String s = Long.toString(n); 27 | int len = s.length(); 28 | 29 | if (len != 15) 30 | return false; 31 | 32 | int sum = 0; 33 | for (int i = len; i >= 1; i--) { 34 | int d = (int) (n % 10); 35 | 36 | // Doubling every alternate digit 37 | if (i % 2 == 0) 38 | d = 2 * d; 39 | 40 | // Finding sum of the digits 41 | sum += sumDig(d); 42 | n = n / 10; 43 | } 44 | 45 | return (sum % 10 == 0); 46 | } 47 | } 48 | 49 | -------------------------------------------------------------------------------- /model/src/main/java/org/openremote/model/teltonika/PayloadJsonObject.java: -------------------------------------------------------------------------------- 1 | package org.openremote.model.teltonika; 2 | 3 | import com.fasterxml.jackson.annotation.*; 4 | 5 | import java.util.LinkedHashMap; 6 | import java.util.Map; 7 | 8 | @JsonInclude(JsonInclude.Include.NON_NULL) 9 | @JsonPropertyOrder({ 10 | "state" 11 | }) 12 | public class PayloadJsonObject { 13 | 14 | @JsonProperty("state") 15 | public State state; 16 | @JsonIgnore 17 | private Map additionalProperties = new LinkedHashMap(); 18 | 19 | @JsonAnyGetter 20 | public Map getAdditionalProperties() { 21 | return this.additionalProperties; 22 | } 23 | 24 | @JsonAnySetter 25 | public void setAdditionalProperty(String name, Object value) { 26 | this.additionalProperties.put(name, value); 27 | } 28 | 29 | @Override 30 | public String toString() { 31 | StringBuilder sb = new StringBuilder(); 32 | sb.append(PayloadJsonObject.class.getName()).append('@').append(Integer.toHexString(System.identityHashCode(this))).append('['); 33 | sb.append("state"); 34 | sb.append('='); 35 | sb.append(((this.state == null) ? "" : this.state)); 36 | sb.append(','); 37 | sb.append("additionalProperties"); 38 | sb.append('='); 39 | sb.append(((this.additionalProperties == null) ? "" : this.additionalProperties)); 40 | sb.append(','); 41 | if (sb.charAt((sb.length() - 1)) == ',') { 42 | sb.setCharAt((sb.length() - 1), ']'); 43 | } else { 44 | sb.append(']'); 45 | } 46 | return sb.toString(); 47 | } 48 | 49 | } 50 | -------------------------------------------------------------------------------- /model/src/main/java/org/openremote/model/teltonika/State.java: -------------------------------------------------------------------------------- 1 | package org.openremote.model.teltonika; 2 | 3 | import com.fasterxml.jackson.annotation.JsonInclude; 4 | import com.fasterxml.jackson.annotation.JsonProperty; 5 | import com.fasterxml.jackson.annotation.JsonPropertyOrder; 6 | 7 | import java.io.Serializable; 8 | import java.util.*; 9 | 10 | @JsonInclude(JsonInclude.Include.NON_NULL) 11 | @JsonPropertyOrder({ 12 | "reported" 13 | }) 14 | public class State implements Serializable { 15 | 16 | // public ReportedState reportedState; 17 | @JsonProperty("reported") 18 | public Map reported; 19 | 20 | @Override 21 | public String toString() { 22 | StringBuilder sb = new StringBuilder(); 23 | sb.append(State.class.getName()).append('@').append(Integer.toHexString(System.identityHashCode(this))).append('['); 24 | sb.append("reported"); 25 | sb.append('='); 26 | sb.append(((this.reported == null) ? "" : this.reported)); 27 | sb.append(','); 28 | if (sb.charAt((sb.length() - 1)) == ',') { 29 | sb.setCharAt((sb.length() - 1), ']'); 30 | } else { 31 | sb.append(']'); 32 | } 33 | return sb.toString(); 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /model/src/main/java/org/openremote/model/teltonika/TeltonikaConfigurationAsset.java: -------------------------------------------------------------------------------- 1 | package org.openremote.model.teltonika; 2 | 3 | import jakarta.persistence.Entity; 4 | import org.openremote.model.asset.Asset; 5 | import org.openremote.model.asset.AssetDescriptor; 6 | import org.openremote.model.attribute.MetaItem; 7 | import org.openremote.model.attribute.MetaMap; 8 | import org.openremote.model.geo.GeoJSONPoint; 9 | import org.openremote.model.value.AttributeDescriptor; 10 | import org.openremote.model.value.MetaItemType; 11 | import org.openremote.model.value.ValueType; 12 | 13 | import java.util.Map; 14 | 15 | @Entity 16 | public class TeltonikaConfigurationAsset extends Asset { 17 | public static final AttributeDescriptor WHITELIST = new AttributeDescriptor<>("deviceIMEIWhitelist", ValueType.TEXT.asArray()).withOptional(true); 18 | public static final AttributeDescriptor ENABLED = new AttributeDescriptor<>("Enabled", ValueType.BOOLEAN); 19 | public static final AttributeDescriptor CHECK_FOR_IMEI = new AttributeDescriptor<>("CheckForValidIMEI", ValueType.BOOLEAN); 20 | public static final AttributeDescriptor STORE_PAYLOADS = new AttributeDescriptor<>("StorePayloads", ValueType.BOOLEAN); 21 | public static final AttributeDescriptor DEFAULT_MODEL_NUMBER = new AttributeDescriptor<>("defaultModelNumber", ValueType.TEXT); 22 | public static final AttributeDescriptor COMMAND = new AttributeDescriptor<>("command", ValueType.TEXT); 23 | public static final AttributeDescriptor RESPONSE = new AttributeDescriptor<>("response", ValueType.TEXT) 24 | .withMeta(TeltonikaModelConfigurationAsset.getPayloadAttributeMeta("Response from device command")); 25 | 26 | 27 | public static final AssetDescriptor DESCRIPTOR = new AssetDescriptor<>("gear", null, TeltonikaConfigurationAsset.class); 28 | 29 | protected TeltonikaConfigurationAsset(){ 30 | 31 | } 32 | 33 | public TeltonikaConfigurationAsset(String name){ 34 | super(name); 35 | super.setLocation(new GeoJSONPoint(0,0,0)); 36 | super.setNotes(""); 37 | } 38 | 39 | public TeltonikaConfigurationAsset setWhitelist(String[] whitelist) { 40 | getAttributes().getOrCreate(WHITELIST).setValue(whitelist); 41 | return this; 42 | } 43 | public TeltonikaConfigurationAsset setEnabled(boolean enabled) { 44 | getAttributes().getOrCreate(ENABLED).setValue(enabled); 45 | return this; 46 | } 47 | 48 | public TeltonikaConfigurationAsset setCheckForImei(boolean enabled) { 49 | getAttributes().getOrCreate(CHECK_FOR_IMEI).setValue(enabled); 50 | return this; 51 | } 52 | 53 | public TeltonikaConfigurationAsset setDefaultModelNumber(String value) { 54 | getAttributes().getOrCreate(DEFAULT_MODEL_NUMBER).setValue(value); 55 | return this; 56 | } 57 | 58 | public TeltonikaConfigurationAsset setCommandTopic(String value){ 59 | getAttributes().getOrCreate(COMMAND).setValue(value); 60 | return this; 61 | } 62 | public TeltonikaConfigurationAsset setResponseTopic(String value){ 63 | getAttributes().getOrCreate(RESPONSE).setValue(value); 64 | return this; 65 | } 66 | 67 | public TeltonikaConfigurationAsset setStorePayloads(Boolean value) { 68 | getAttributes().getOrCreate(STORE_PAYLOADS).setValue(value); 69 | return this; 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /model/src/main/java/org/openremote/model/teltonika/TeltonikaDataPayloadModel.java: -------------------------------------------------------------------------------- 1 | package org.openremote.model.teltonika; 2 | 3 | import com.fasterxml.jackson.annotation.JsonInclude; 4 | import com.fasterxml.jackson.annotation.JsonProperty; 5 | import com.fasterxml.jackson.annotation.JsonPropertyOrder; 6 | 7 | import java.io.Serializable; 8 | 9 | @JsonInclude(JsonInclude.Include.NON_NULL) 10 | @JsonPropertyOrder({ 11 | "payload" 12 | }) 13 | public class TeltonikaDataPayloadModel implements Serializable { 14 | 15 | @JsonProperty("state") 16 | public State state; 17 | 18 | public TeltonikaDataPayloadModel() { 19 | } 20 | 21 | public TeltonikaDataPayloadModel(State state) { 22 | this.state = state; 23 | } 24 | 25 | } 26 | -------------------------------------------------------------------------------- /model/src/main/java/org/openremote/model/teltonika/TeltonikaModelConfigurationAsset.java: -------------------------------------------------------------------------------- 1 | package org.openremote.model.teltonika; 2 | 3 | import jakarta.persistence.Entity; 4 | import org.openremote.model.asset.Asset; 5 | import org.openremote.model.asset.AssetDescriptor; 6 | import org.openremote.model.attribute.Attribute; 7 | import org.openremote.model.attribute.MetaItem; 8 | import org.openremote.model.attribute.MetaMap; 9 | import org.openremote.model.custom.CustomValueTypes; 10 | import org.openremote.model.geo.GeoJSONPoint; 11 | import org.openremote.model.value.AttributeDescriptor; 12 | import org.openremote.model.value.MetaItemType; 13 | import org.openremote.model.value.ValueType; 14 | 15 | import java.util.Arrays; 16 | import java.util.Map; 17 | import java.util.Optional; 18 | import java.util.stream.Collectors; 19 | 20 | import static org.openremote.model.value.MetaItemType.*; 21 | 22 | @Entity 23 | public class TeltonikaModelConfigurationAsset extends Asset { 24 | public static final AttributeDescriptor MODEL_NUMBER = new AttributeDescriptor<>("modelNumber", ValueType.TEXT); 25 | public static final AttributeDescriptor PARAMETER_DATA = new AttributeDescriptor<>("TeltonikaParameterData", CustomValueTypes.TELTONIKA_PARAMETER.asArray()); 26 | 27 | public static final AttributeDescriptor PARAMETER_MAP = new AttributeDescriptor<>("TeltonikaParameterMap", CustomValueTypes.TELTONIKA_PARAMETER_MAP) 28 | .withMeta(new MetaMap(Map.of(MetaItemType.READ_ONLY.getName(), new MetaItem<>(MetaItemType.READ_ONLY, true)))); 29 | public static final AssetDescriptor DESCRIPTOR = new AssetDescriptor<>("switch", null, TeltonikaModelConfigurationAsset.class); 30 | 31 | protected TeltonikaModelConfigurationAsset(){} 32 | 33 | public TeltonikaModelConfigurationAsset(String name){ 34 | super(name); 35 | super.setLocation(new GeoJSONPoint(0,0,0)); 36 | super.setNotes(""); 37 | } 38 | 39 | public TeltonikaModelConfigurationAsset setModelNumber(String name) { 40 | getAttributes().getOrCreate(MODEL_NUMBER).setValue(name); 41 | return this; 42 | } 43 | 44 | public TeltonikaModelConfigurationAsset setParameterData(TeltonikaParameter[] data) { 45 | //Get TeltonikaParameter array, cast to Map with parameter ID as key, save to PARAMETER_MAP, remove from PARAMETER_DATA 46 | getAttributes().getOrCreate(PARAMETER_DATA).setValue(data); 47 | CustomValueTypes.TeltonikaParameterMap map = Arrays.stream(data).collect(Collectors.toMap( 48 | TeltonikaParameter::getPropertyId, // Key Mapper 49 | param -> param, // Value Mapper 50 | (existing, replacement) -> replacement, // Merge Function 51 | CustomValueTypes.TeltonikaParameterMap::new 52 | )); 53 | 54 | getAttributes().getOrCreate(PARAMETER_MAP).setValue(map); 55 | 56 | // 57 | 58 | 59 | 60 | return this; 61 | } 62 | 63 | public Optional getModelNumber(){ 64 | return getAttributes().getValue(MODEL); 65 | } 66 | 67 | public CustomValueTypes.TeltonikaParameterMap getParameterMap() { 68 | Optional> map = getAttributes().get(PARAMETER_MAP); 69 | 70 | return map.flatMap(Attribute::getValue) 71 | .orElse(new CustomValueTypes.TeltonikaParameterMap()); // or provide a default value other than null, if appropriate 72 | } 73 | 74 | public static MetaMap getPayloadAttributeMeta(String label){ 75 | MetaMap map = new MetaMap(); 76 | 77 | map.addAll( 78 | new MetaItem<>(STORE_DATA_POINTS, true), 79 | new MetaItem<>(RULE_STATE, true), 80 | new MetaItem<>(READ_ONLY, true), 81 | new MetaItem<>(LABEL, label) 82 | ); 83 | 84 | return map; 85 | } 86 | 87 | } 88 | -------------------------------------------------------------------------------- /model/src/main/java/org/openremote/model/teltonika/TeltonikaParameter.java: -------------------------------------------------------------------------------- 1 | 2 | package org.openremote.model.teltonika; 3 | 4 | import java.io.Serializable; 5 | import java.util.LinkedHashMap; 6 | import java.util.Map; 7 | import java.util.Objects; 8 | 9 | import com.fasterxml.jackson.annotation.JsonAnyGetter; 10 | import com.fasterxml.jackson.annotation.JsonAnySetter; 11 | import com.fasterxml.jackson.annotation.JsonIgnore; 12 | import com.fasterxml.jackson.annotation.JsonInclude; 13 | import com.fasterxml.jackson.annotation.JsonProperty; 14 | import com.fasterxml.jackson.annotation.JsonPropertyOrder; 15 | 16 | @JsonInclude(JsonInclude.Include.NON_NULL) 17 | @JsonPropertyOrder({ 18 | "propertyIdInAvlPacket", 19 | "propertyName", 20 | "bytes", 21 | "type", 22 | "min", 23 | "max", 24 | "multiplier", 25 | "units", 26 | "description", 27 | "hwSupport", 28 | "parameterGroup" 29 | }) 30 | //TODO: Create interface for this class called TelematicsDeviceParameter 31 | public class TeltonikaParameter implements Serializable { 32 | 33 | @JsonProperty("propertyIdInAvlPacket") 34 | public Integer propertyId; 35 | @JsonProperty("propertyName") 36 | public String propertyName; 37 | @JsonProperty("bytes") 38 | public String bytes; 39 | @JsonProperty("type") 40 | public String type; 41 | @JsonProperty("min") 42 | public String min; 43 | @JsonProperty("max") 44 | public String max; 45 | @JsonProperty("multiplier") 46 | public String multiplier; 47 | @JsonProperty("units") 48 | public String units; 49 | @JsonProperty("description") 50 | public String description; 51 | @JsonProperty("hwSupport") 52 | public String hwSupport; 53 | @JsonProperty("parameterGroup") 54 | public String parameterGroup; 55 | 56 | @JsonIgnore 57 | private Map additionalProperties = new LinkedHashMap(); 58 | 59 | public TeltonikaParameter(Integer propertyId, String propertyName, String bytes, String type, String min, String max, String multiplier, String units, String description, String hwSupport, String parameterGroup) { 60 | this.propertyId = propertyId; 61 | this.propertyName = propertyName; 62 | this.bytes = bytes; 63 | this.type = type; 64 | this.min = min; 65 | this.max = max; 66 | this.multiplier = multiplier; 67 | this.units = units; 68 | this.description = description; 69 | this.hwSupport = hwSupport; 70 | this.parameterGroup = parameterGroup; 71 | } 72 | 73 | public TeltonikaParameter(){} 74 | 75 | 76 | @JsonAnyGetter 77 | public Map getAdditionalProperties() { 78 | return this.additionalProperties; 79 | } 80 | 81 | @JsonAnySetter 82 | public void setAdditionalProperty(String name, Object value) { 83 | this.additionalProperties.put(name, value); 84 | } 85 | 86 | 87 | 88 | @Override 89 | public String toString() { 90 | StringBuilder sb = new StringBuilder(); 91 | sb.append(TeltonikaParameter.class.getName()).append('@').append(Integer.toHexString(System.identityHashCode(this))).append('['); 92 | sb.append("propertyIdInAvlPacket"); 93 | sb.append('='); 94 | sb.append(((this.propertyId == null)?"":this.propertyId)); 95 | sb.append(','); 96 | sb.append("propertyName"); 97 | sb.append('='); 98 | sb.append(((this.propertyName == null)?"":this.propertyName)); 99 | sb.append(','); 100 | sb.append("bytes"); 101 | sb.append('='); 102 | sb.append(((this.bytes == null)?"":this.bytes)); 103 | sb.append(','); 104 | sb.append("type"); 105 | sb.append('='); 106 | sb.append(((this.type == null)?"":this.type)); 107 | sb.append(','); 108 | sb.append("min"); 109 | sb.append('='); 110 | sb.append(((this.min == null)?"":this.min)); 111 | sb.append(','); 112 | sb.append("max"); 113 | sb.append('='); 114 | sb.append(((this.max == null)?"":this.max)); 115 | sb.append(','); 116 | sb.append("multiplier"); 117 | sb.append('='); 118 | sb.append(((this.multiplier == null)?"":this.multiplier)); 119 | sb.append(','); 120 | sb.append("units"); 121 | sb.append('='); 122 | sb.append(((this.units == null)?"":this.units)); 123 | sb.append(','); 124 | sb.append("description"); 125 | sb.append('='); 126 | sb.append(((this.description == null)?"":this.description)); 127 | sb.append(','); 128 | sb.append("hwSupport"); 129 | sb.append('='); 130 | sb.append(((this.hwSupport == null)?"":this.hwSupport)); 131 | sb.append(','); 132 | sb.append("parameterGroup"); 133 | sb.append('='); 134 | sb.append(((this.parameterGroup == null)?"":this.parameterGroup)); 135 | sb.append(','); 136 | sb.append("additionalProperties"); 137 | sb.append('='); 138 | sb.append(((this.additionalProperties == null)?"":this.additionalProperties)); 139 | sb.append(','); 140 | if (sb.charAt((sb.length()- 1)) == ',') { 141 | sb.setCharAt((sb.length()- 1), ']'); 142 | } else { 143 | sb.append(']'); 144 | } 145 | return sb.toString(); 146 | } 147 | 148 | public Integer getPropertyId(){ 149 | return this.propertyId; 150 | } 151 | 152 | @Override 153 | public boolean equals(Object param) { 154 | 155 | if(param.getClass() == this.getClass()){ 156 | return Objects.equals(((TeltonikaParameter)param).getPropertyId(), this.getPropertyId()); 157 | } return false; 158 | } 159 | } 160 | -------------------------------------------------------------------------------- /model/src/main/resources/META-INF/services/org.openremote.model.AssetModelProvider: -------------------------------------------------------------------------------- 1 | org.openremote.model.custom.CustomAssetModelProvider 2 | -------------------------------------------------------------------------------- /package-lock.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "fleet-management", 3 | "lockfileVersion": 3, 4 | "requires": true, 5 | "packages": { 6 | "": { 7 | "name": "fleet-management" 8 | } 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "packageManager": "yarn@4.3.1", 3 | "private": true, 4 | "workspaces": [ 5 | "openremote", 6 | "ui/app/*", 7 | "ui/component/*", 8 | "ui/demo/*" 9 | ] 10 | } 11 | -------------------------------------------------------------------------------- /profile/dev-testing.yml: -------------------------------------------------------------------------------- 1 | # OpenRemote v3 2 | # 3 | # Profile for doing keycloak custom theme development 4 | # 5 | # Please see profile/deploy.yml for configuration details for each service. 6 | # 7 | version: '2.4' 8 | 9 | services: 10 | 11 | keycloak: 12 | extends: 13 | file: ../openremote/profile/deploy.yml 14 | service: keycloak 15 | volumes: 16 | # Map custom themes 17 | - ../deployment:/deployment 18 | # Access directly if needed on localhost 19 | ports: 20 | - "8081:8080" 21 | depends_on: 22 | postgresql: 23 | condition: service_healthy 24 | environment: 25 | KC_HOSTNAME_STRICT_HTTPS: 'false' 26 | KC_HOSTNAME_PORT: ${KC_HOSTNAME_PORT:-8080} 27 | # Prevent theme caching during dev 28 | KEYCLOAK_START_OPTS: --spi-theme-static-max-age=-1 --spi-theme-cache-themes=false --spi-theme-cache-templates=false 29 | 30 | postgresql: 31 | extends: 32 | file: ../openremote/profile/deploy.yml 33 | service: postgresql 34 | volumes: 35 | - ../openremote/tmp:/storage 36 | # Access directly if needed on localhost 37 | ports: 38 | - "5432:5432" 39 | -------------------------------------------------------------------------------- /profile/dev-ui.yml: -------------------------------------------------------------------------------- 1 | # OpenRemote v3 2 | # 3 | # Profile for doing UI development work. 4 | # 5 | # Please see profile/deploy.yml for configuration details for each service. 6 | # 7 | version: '2.4' 8 | 9 | volumes: 10 | manager-data: 11 | 12 | services: 13 | 14 | keycloak: 15 | extends: 16 | file: ../openremote/profile/deploy.yml 17 | service: keycloak 18 | volumes: 19 | # Map custom themes 20 | - ../deployment:/deployment 21 | # Access directly if needed on localhost 22 | ports: 23 | - "8081:8080" 24 | depends_on: 25 | postgresql: 26 | condition: service_healthy 27 | environment: 28 | KC_HOSTNAME: ${OR_HOSTNAME:-localhost} 29 | KC_HOSTNAME_STRICT_HTTPS: 'false' 30 | KC_HOSTNAME_PORT: ${KC_HOSTNAME_PORT:-8080} 31 | # Prevent theme caching during dev 32 | KEYCLOAK_START_OPTS: --spi-theme-static-max-age=-1 --spi-theme-cache-themes=false --spi-theme-cache-templates=false 33 | 34 | postgresql: 35 | extends: 36 | file: ../openremote/profile/deploy.yml 37 | service: postgresql 38 | volumes: 39 | - manager-data:/storage 40 | # Access directly if needed on localhost 41 | ports: 42 | - "5432:5432" 43 | 44 | manager: 45 | extends: 46 | file: ../openremote/profile/deploy.yml 47 | service: manager 48 | depends_on: 49 | postgresql: 50 | condition: service_healthy 51 | volumes: 52 | - manager-data:/storage 53 | - ../deployment/build/image:/deployment 54 | environment: 55 | OR_SETUP_RUN_ON_RESTART: ${OR_SETUP_RUN_ON_RESTART:-true} 56 | OR_DEV_MODE: ${OR_DEV_MODE:-true} 57 | KC_HOSTNAME_PORT: ${KC_HOSTNAME_PORT:-8080} 58 | ports: 59 | - "8080:8080" 60 | -------------------------------------------------------------------------------- /profile/prod_cicd.yml: -------------------------------------------------------------------------------- 1 | # OpenRemote v3 2 | # 3 | # Profile for deploying the custom stack; uses deployment-data named volume 4 | # to expose customisations to the manager and keycloak images. To run this profile you need to specify the following 5 | # environment variables: 6 | # 7 | # OR_ADMIN_PASSWORD - Initial admin user password 8 | # OR_HOSTNAME - FQDN hostname of where this instance will be exposed (localhost, IP address or public domain) 9 | # DEPLOYMENT_VERSION - Tag to use for deployment image (must match the tag used when building the deployment image) 10 | # 11 | # Please see openremote/profile/deploy.yml for configuration details for each service. 12 | # 13 | # To perform updates, build code and prepare Docker images: 14 | # 15 | # ./gradlew clean installDist 16 | # 17 | # Then recreate deployment image: 18 | # 19 | # DEPLOYMENT_VERSION=$(git rev-parse --short HEAD) 20 | # MANAGER_VERSION=$(cd openremote; git rev-parse --short HEAD; cd ..) 21 | # docker build -t openremote/manager:$MANAGER_VERSION ./openremote/manager/build/install/manager/ 22 | # docker build -t openremote/custom-deployment:$DEPLOYMENT_VERSION ./deployment/build/ 23 | # docker-compose -p custom down 24 | # docker volume rm custom_deployment-data 25 | # Do the following volume rm command if you want a clean install (wipe all existing data) 26 | # docker volume rm custom_postgresql-data 27 | # OR_ADMIN_PASSWORD=secret OR_HOSTNAME=my.domain.com docker-compose -p custom up -d 28 | # 29 | # All data is kept in volumes. Create a backup of the volumes to preserve data. 30 | # 31 | version: '2.4' 32 | 33 | volumes: 34 | proxy-data: 35 | deployment-data: 36 | postgresql-data: 37 | manager-data: 38 | 39 | # Add an NFS volume to the stack 40 | efs-data: 41 | driver: local 42 | driver_opts: 43 | type: nfs 44 | o: "addr=${EFS_DNS?DNS must be set to mount NFS volume},rw,nfsvers=4.1,rsize=1048576,wsize=1048576,hard,timeo=600,retrans=2,noresvport" 45 | device: ":/" 46 | 47 | x-logging: &awslogs 48 | logging: 49 | driver: awslogs 50 | options: 51 | awslogs-region: ${AWS_REGION:-eu-west-1} 52 | awslogs-group: ${OR_HOSTNAME} 53 | awslogs-create-group: 'true' 54 | tag: "{{.Name}}/{{.ID}}" 55 | 56 | services: 57 | 58 | # This service will only populate an empty volume on startup and then exit. 59 | # If the volume already contains data, it exits immediately. 60 | deployment: 61 | image: openremote/deployment:${DEPLOYMENT_VERSION?DEPLOYMENT_VERSION must be set} 62 | volumes: 63 | - deployment-data:/deployment 64 | 65 | proxy: 66 | image: openremote/proxy:${PROXY_VERSION:-latest} 67 | restart: always 68 | depends_on: 69 | manager: 70 | condition: service_healthy 71 | ports: 72 | - "80:80" 73 | - "443:443" 74 | - "8883:8883" 75 | volumes: 76 | - proxy-data:/deployment 77 | - deployment-data:/data 78 | environment: 79 | LE_EMAIL: ${OR_EMAIL_ADMIN} 80 | DOMAINNAME: ${OR_HOSTNAME?OR_HOSTNAME must be set} 81 | DOMAINNAMES: ${OR_ADDITIONAL_HOSTNAMES:-} 82 | # USE A CUSTOM PROXY CONFIG - COPY FROM https://github.com/openremote/proxy/blob/main/haproxy.cfg 83 | #HAPROXY_CONFIG: '/data/proxy/haproxy.cfg' 84 | <<: *awslogs 85 | 86 | postgresql: 87 | image: openremote/postgresql:${POSTGRESQL_VERSION:-latest} 88 | restart: always 89 | volumes: 90 | - postgresql-data:/var/lib/postgresql/data 91 | - manager-data:/storage 92 | <<: *awslogs 93 | 94 | keycloak: 95 | image: openremote/keycloak:${KEYCLOAK_VERSION:-latest} 96 | restart: always 97 | depends_on: 98 | postgresql: 99 | condition: service_healthy 100 | volumes: 101 | - deployment-data:/deployment 102 | environment: 103 | KEYCLOAK_ADMIN_PASSWORD: ${OR_ADMIN_PASSWORD:?OR_ADMIN_PASSWORD must be set} 104 | KC_HOSTNAME: ${OR_HOSTNAME:-localhost} 105 | KC_HOSTNAME_PORT: ${OR_SSL_PORT:--1} 106 | <<: *awslogs 107 | 108 | manager: 109 | image: openremote/manager:${MANAGER_VERSION:-latest} 110 | restart: always 111 | depends_on: 112 | keycloak: 113 | condition: service_healthy 114 | volumes: 115 | - manager-data:/storage 116 | - deployment-data:/deployment 117 | # Map data should be accessed from a volume mount 118 | # 1). Host filesystem - /deployment.local:/deployment.local 119 | # 2) NFS/EFS network mount - efs-data:/efs 120 | - efs-data:/efs 121 | environment: 122 | # Here are some typical environment variables you want to set 123 | # see openremote/profile/deploy.yml for details 124 | OR_ADMIN_PASSWORD: ${OR_ADMIN_PASSWORD?OR_ADMIN_PASSWORD must be set} 125 | OR_SETUP_TYPE: # Typical values to support are staging and production 126 | OR_SETUP_RUN_ON_RESTART: 127 | OR_EMAIL_HOST: 128 | OR_EMAIL_USER: 129 | OR_EMAIL_PASSWORD: 130 | OR_EMAIL_X_HEADERS: 131 | OR_EMAIL_FROM: 132 | OR_EMAIL_ADMIN: 133 | OR_HOSTNAME: ${OR_HOSTNAME?OR_HOSTNAME must be set} 134 | OR_ADDITIONAL_HOSTNAMES: ${OR_ADDITIONAL_HOSTNAMES:-} 135 | OR_SSL_PORT: ${OR_SSL_PORT:--1} 136 | OR_DEV_MODE: ${OR_DEV_MODE:-false} 137 | OR_MAP_TILES_PATH: '/efs/europe.mbtiles' 138 | #OR_MAP_TILES_PATH: '/efs/europe.mbtiles' 139 | <<: *awslogs 140 | -------------------------------------------------------------------------------- /settings.gradle: -------------------------------------------------------------------------------- 1 | plugins { 2 | id "com.cherryperry.gradle-file-encrypt" version "2.0.3" apply false 3 | id "org.jetbrains.gradle.plugin.idea-ext" version "1.1.3" apply false 4 | id 'cz.habarta.typescript-generator' version "$typescriptGeneratorVersion" apply false 5 | } 6 | 7 | rootProject.name = "$projectName" 8 | 9 | // Include sub-projects dynamically, every directory with a build.gradle (and no .buildignore) 10 | fileTree(dir: rootDir, include: "**/build.gradle", excludes: ["**/node_modules/**", "**/generic_app/**"]) 11 | .filter { it.parent != rootDir } 12 | .filter { !file("${it.parent}/.buildignore").exists() } 13 | .each { 14 | include it.parent.replace(rootDir.canonicalPath, "").replace("\\", ":").replace("/", ":") 15 | } -------------------------------------------------------------------------------- /setup/build.gradle: -------------------------------------------------------------------------------- 1 | apply plugin: "java-library" 2 | 3 | dependencies { 4 | api project(":model") 5 | api project(":manager") 6 | } 7 | 8 | task installDist { 9 | dependsOn jar 10 | } 11 | -------------------------------------------------------------------------------- /setup/src/main/java/org/openremote/manager/setup/custom/CustomKeycloakSetup.java: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2021, OpenRemote Inc. 3 | * 4 | * See the CONTRIBUTORS.txt file in the distribution for a 5 | * full listing of individual contributors. 6 | * 7 | * This program is free software: you can redistribute it and/or modify 8 | * it under the terms of the GNU Affero General Public License as 9 | * published by the Free Software Foundation, either version 3 of the 10 | * License, or (at your option) any later version. 11 | * 12 | * This program is distributed in the hope that it will be useful, 13 | * but WITHOUT ANY WARRANTY; without even the implied warranty of 14 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 15 | * GNU Affero General Public License for more details. 16 | * 17 | * You should have received a copy of the GNU Affero General Public License 18 | * along with this program. If not, see . 19 | */ 20 | package org.openremote.manager.setup.custom; 21 | 22 | import org.openremote.manager.setup.AbstractKeycloakSetup; 23 | import org.openremote.model.Constants; 24 | import org.openremote.model.Container; 25 | import org.openremote.model.security.User; 26 | 27 | public class CustomKeycloakSetup extends AbstractKeycloakSetup { 28 | 29 | protected User serviceUser; 30 | 31 | public CustomKeycloakSetup(Container container, boolean isProduction) { 32 | super(container); 33 | } 34 | 35 | @Override 36 | public void onStart() throws Exception { 37 | 38 | serviceUser = new User() 39 | .setServiceAccount(true) 40 | .setEnabled(true) 41 | .setUsername("serviceUser"); 42 | 43 | serviceUser = keycloakProvider.createUpdateUser(Constants.MASTER_REALM, serviceUser, null, true); 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /setup/src/main/java/org/openremote/manager/setup/custom/CustomManagerSetup.java: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2021, OpenRemote Inc. 3 | * 4 | * See the CONTRIBUTORS.txt file in the distribution for a 5 | * full listing of individual contributors. 6 | * 7 | * This program is free software: you can redistribute it and/or modify 8 | * it under the terms of the GNU Affero General Public License as 9 | * published by the Free Software Foundation, either version 3 of the 10 | * License, or (at your option) any later version. 11 | * 12 | * This program is distributed in the hope that it will be useful, 13 | * but WITHOUT ANY WARRANTY; without even the implied warranty of 14 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 15 | * GNU Affero General Public License for more details. 16 | * 17 | * You should have received a copy of the GNU Affero General Public License 18 | * along with this program. If not, see . 19 | */ 20 | package org.openremote.manager.setup.custom; 21 | 22 | import org.openremote.manager.setup.ManagerSetup; 23 | import org.openremote.model.Constants; 24 | import org.openremote.model.Container; 25 | import org.openremote.model.asset.impl.ThingAsset; 26 | 27 | public class CustomManagerSetup extends ManagerSetup { 28 | 29 | public CustomManagerSetup(Container container) { 30 | super(container); 31 | } 32 | 33 | @Override 34 | public void onStart() throws Exception { 35 | super.onStart(); 36 | 37 | // Create assets on clean startup here 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /setup/src/main/java/org/openremote/manager/setup/custom/CustomSetupTasks.java: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2021, OpenRemote Inc. 3 | * 4 | * See the CONTRIBUTORS.txt file in the distribution for a 5 | * full listing of individual contributors. 6 | * 7 | * This program is free software: you can redistribute it and/or modify 8 | * it under the terms of the GNU Affero General Public License as 9 | * published by the Free Software Foundation, either version 3 of the 10 | * License, or (at your option) any later version. 11 | * 12 | * This program is distributed in the hope that it will be useful, 13 | * but WITHOUT ANY WARRANTY; without even the implied warranty of 14 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 15 | * GNU Affero General Public License for more details. 16 | * 17 | * You should have received a copy of the GNU Affero General Public License 18 | * along with this program. If not, see . 19 | */ 20 | package org.openremote.manager.setup.custom; 21 | 22 | import org.openremote.model.Container; 23 | import org.openremote.model.setup.Setup; 24 | import org.openremote.model.setup.SetupTasks; 25 | 26 | import java.util.Arrays; 27 | import java.util.List; 28 | 29 | public class CustomSetupTasks implements SetupTasks { 30 | 31 | public static final String PRODUCTION = "production"; 32 | 33 | @Override 34 | public List createTasks(Container container, String setupType, boolean keycloakEnabled) { 35 | 36 | boolean isProduction = PRODUCTION.equalsIgnoreCase(setupType); 37 | 38 | // Add custom Setup task implementations here with tasks optionally dependent on setupType 39 | return Arrays.asList( 40 | new CustomKeycloakSetup(container, isProduction), 41 | new CustomManagerSetup(container) 42 | ); 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /setup/src/main/resources/META-INF/services/org.openremote.model.setup.SetupTasks: -------------------------------------------------------------------------------- 1 | org.openremote.manager.setup.custom.CustomSetupTasks 2 | -------------------------------------------------------------------------------- /test/build.gradle: -------------------------------------------------------------------------------- 1 | import org.gradle.api.tasks.testing.logging.TestExceptionFormat 2 | import org.gradle.api.tasks.testing.logging.TestLogEvent 3 | 4 | plugins { 5 | id 'com.adarshr.test-logger' version '3.1.0' 6 | } 7 | 8 | apply plugin: "java-library" 9 | apply plugin: "groovy" 10 | 11 | dependencies { 12 | api project(":manager") 13 | api project(":model") 14 | api project(":setup") 15 | testImplementation(testFixtures(resolveProject(":test"))) 16 | } 17 | 18 | tasks.withType(Test) { 19 | environment("LOGGING_CONFIG_FILE", "test/src/logging-test.properties") 20 | } 21 | 22 | test { 23 | workingDir = findProject(":openremote") != null ? project(":openremote").projectDir : rootProject.projectDir 24 | useJUnitPlatform() 25 | testLogging { 26 | // set options for log level LIFECYCLE 27 | events TestLogEvent.FAILED, 28 | TestLogEvent.PASSED, 29 | TestLogEvent.SKIPPED 30 | exceptionFormat TestExceptionFormat.FULL 31 | showExceptions true 32 | showCauses true 33 | showStackTraces true 34 | 35 | // set options for log level DEBUG and INFO 36 | debug { 37 | events TestLogEvent.STARTED, 38 | TestLogEvent.FAILED, 39 | TestLogEvent.PASSED, 40 | TestLogEvent.SKIPPED, 41 | TestLogEvent.STANDARD_ERROR 42 | exceptionFormat TestExceptionFormat.FULL 43 | } 44 | info.events = debug.events 45 | info.exceptionFormat = debug.exceptionFormat 46 | 47 | afterTest { desc, result -> 48 | logger.quiet "${desc.className} > ${desc.name} took: ${(result.endTime - result.startTime)}ms" 49 | } 50 | 51 | afterSuite { desc, result -> 52 | if (!desc.parent) { // will match the outermost suite 53 | def output = "Results: ${result.resultType} (${result.testCount} tests, ${result.successfulTestCount} passed, ${result.failedTestCount} failed, ${result.skippedTestCount} skipped)" 54 | def startItem = '| ', endItem = ' |' 55 | def repeatLength = startItem.length() + output.length() + endItem.length() 56 | println('\n' + ('-' * repeatLength) + '\n' + startItem + output + endItem + '\n' + ('-' * repeatLength)) 57 | } 58 | } 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /test/src/test/groovy/org/openremote/test/custom/TeltonikaMQTTProtocolTest.groovy: -------------------------------------------------------------------------------- 1 | package org.openremote.test.custom 2 | 3 | import com.fasterxml.jackson.databind.ObjectMapper 4 | import groovy.json.JsonOutput 5 | import groovy.json.JsonSlurper 6 | import org.openremote.agent.protocol.mqtt.MQTTMessage 7 | import org.openremote.agent.protocol.mqtt.MQTT_IOClient 8 | import org.openremote.container.Container 9 | import org.openremote.container.util.UniqueIdentifierGenerator 10 | import org.openremote.manager.agent.AgentService 11 | import org.openremote.manager.asset.AssetProcessingService 12 | import org.openremote.manager.asset.AssetStorageService 13 | import org.openremote.manager.event.ClientEventService 14 | import org.openremote.manager.mqtt.MQTTBrokerService 15 | import org.openremote.model.custom.AssetStateDuration 16 | import org.openremote.model.custom.CustomValueTypes 17 | import org.openremote.model.query.AssetQuery 18 | import telematics.teltonika.TeltonikaMQTTHandler 19 | import org.openremote.manager.setup.SetupService 20 | import org.openremote.manager.setup.custom.CustomKeycloakSetup 21 | import org.openremote.model.Constants 22 | import org.openremote.model.asset.Asset 23 | import org.openremote.model.asset.agent.ConnectionStatus 24 | import org.openremote.model.attribute.Attribute 25 | import org.openremote.model.attribute.AttributeEvent 26 | import org.openremote.model.attribute.AttributeRef 27 | import org.openremote.model.custom.VehicleAsset 28 | import org.openremote.model.teltonika.TeltonikaParameter 29 | import org.openremote.model.util.ValueUtil 30 | import org.openremote.model.value.MetaItemType 31 | import org.openremote.model.value.ValueType 32 | import org.openremote.test.ManagerContainerTrait 33 | import spock.lang.Shared 34 | import spock.lang.Specification 35 | import spock.util.concurrent.PollingConditions 36 | 37 | class TeltonikaMQTTProtocolTest extends Specification implements ManagerContainerTrait { 38 | @Shared public def conditions = new PollingConditions(timeout: 10, delay: 0.1) 39 | public static Map params; 40 | 41 | public static Container container 42 | public static AssetStorageService assetStorageService; 43 | public static AssetProcessingService assetProcessingService; 44 | public static AgentService agentService; 45 | public static MQTTBrokerService mqttBrokerService 46 | public static ClientEventService clientEventService 47 | public static String mqttHost 48 | public static int mqttPort 49 | public static String mqttClientId 50 | public static String username 51 | public static String password 52 | public static MQTT_IOClient client 53 | public static TeltonikaMQTTHandler handler 54 | 55 | //TODO: I do not know how/if to link this to environment variable 56 | String TELTONIKA_DEVICE_RECEIVE_TOPIC = "data" 57 | String TELTONIKA_DEVICE_SEND_TOPIC = "commands"; 58 | String TELTONIKA_DEVICE_TOKEN = "teltonika"; 59 | String TELTONIKA_DEVICE_SEND_COMMAND_ATTRIBUTE_NAME = "sendToDevice"; 60 | String TELTONIKA_DEVICE_RECEIVE_COMMAND_ATTRIBUTE_NAME = "response"; 61 | //Real IMEI: https://www.imei.info/?imei=358491098808487 62 | def TELTONIKA_DEVICE_IMEI = "358491098808487"; 63 | 64 | Map retrieveTeltonikaParams() { 65 | ObjectMapper mapper = new ObjectMapper(); 66 | String jsonText = new File("../deployment/manager/fleet/FMC003.json").text 67 | TeltonikaParameter[] paramArray = mapper.readValue(jsonText, TeltonikaParameter[].class) 68 | Map params = new HashMap() 69 | for (final def param in paramArray) { 70 | params.put(param.getPropertyId().toString(), param) 71 | } 72 | return params 73 | } 74 | 75 | def setupSpec() { 76 | given: "the container is started" 77 | container = startContainer(defaultConfig(), defaultServices()) 78 | assetStorageService = container.getService(AssetStorageService.class) 79 | assetProcessingService = container.getService(AssetProcessingService.class) 80 | agentService = container.getService(AgentService.class) 81 | mqttBrokerService = container.getService(MQTTBrokerService.class) 82 | clientEventService = container.getService(ClientEventService.class) 83 | def keycloakTestSetup = container.getService(SetupService.class).getTaskOfType(CustomKeycloakSetup.class) 84 | handler = mqttBrokerService.customHandlers.find {it instanceof TeltonikaMQTTHandler} as TeltonikaMQTTHandler 85 | 86 | mqttHost = mqttBrokerService.host 87 | mqttPort = mqttBrokerService.port 88 | 89 | mqttClientId = UniqueIdentifierGenerator.generateId() 90 | username = Constants.MASTER_REALM + ":" + keycloakTestSetup.serviceUser.username // realm and OAuth client id 91 | password = keycloakTestSetup.serviceUser.secret 92 | 93 | params = retrieveTeltonikaParams() 94 | 95 | 96 | client = new MQTT_IOClient(mqttClientId, mqttHost, mqttPort, false, false, null, null, null) 97 | } 98 | 99 | def setup(){ 100 | // I think that it is required to provide usernamePassword to connect the client to the broker. Look at AbstractMQTT_IOClient:L100. 101 | client.removeAllMessageConsumers() 102 | client.disconnect() 103 | 104 | client = new MQTT_IOClient(mqttClientId, mqttHost, mqttPort, false, false, null, null, null) 105 | } 106 | 107 | def cleanupSpec(){ 108 | getLOG().debug("cleanupSpec") 109 | container.stop() 110 | } 111 | 112 | def "the device connects to the MQTT broker"() { 113 | 114 | when: "the device connects to the MQTT broker" 115 | client.connect() 116 | 117 | 118 | then: "mqtt connection should exist" 119 | conditions.eventually { 120 | assert client.getConnectionStatus() == ConnectionStatus.CONNECTED 121 | def connection = mqttBrokerService.getConnectionFromClientID(mqttClientId) 122 | assert connection != null 123 | } 124 | 125 | 126 | cleanup: "disconnect client from broker" 127 | client.disconnect() 128 | } 129 | 130 | def "the device connects to the correct data topic" () { 131 | when: "client connects to the MQTT broker and to the correct data topic" 132 | 133 | String dataTopic = "${Constants.MASTER_REALM}/${mqttClientId}/${TELTONIKA_DEVICE_TOKEN}/${TELTONIKA_DEVICE_IMEI}/${TELTONIKA_DEVICE_RECEIVE_TOPIC}".toString(); 134 | client.connect(); 135 | 136 | then: "mqtt connection should exist" 137 | conditions.eventually { 138 | assert client.getConnectionStatus() == ConnectionStatus.CONNECTED 139 | def connection = mqttBrokerService.getConnectionFromClientID(mqttClientId) 140 | assert connection != null 141 | } 142 | 143 | client.addMessageConsumer(dataTopic, { _ -> return}); 144 | 145 | and: "There should be a subscription handled by TeltonikaMQTTHandler" 146 | conditions.eventually { 147 | // FOR SOME REASON, MQTTBrokerService.java:252 considers this connection internal, 148 | // so it returns void without going through with the subscription *??????????* 149 | // I think that client.cleanSession is what allows this to not be internal 150 | assert handler.connectionSubscriberInfoMap.containsKey(getTELTONIKA_DEVICE_IMEI()); 151 | } 152 | 153 | cleanup: "disconnect client from broker" 154 | client.removeAllMessageConsumers() 155 | client.disconnect() 156 | } 157 | 158 | def "the device connects to the MQTT broker to a data topic without TELTONIKA_DEVICE_TOKEN"() { 159 | 160 | 161 | when: "the device connects to the MQTT broker to a data topic without TELTONIKA_DEVICE_TOKEN" 162 | 163 | def incorrectDataTopic1 = "${Constants.MASTER_REALM}/${mqttClientId}/${TELTONIKA_DEVICE_IMEI}/${TELTONIKA_DEVICE_RECEIVE_TOPIC}".toString(); 164 | 165 | client.connect() 166 | client.addMessageConsumer(incorrectDataTopic1, { _ -> return }); 167 | then: "mqtt connection should exist" 168 | conditions.eventually { 169 | assert client.getConnectionStatus() == ConnectionStatus.CONNECTED 170 | def connection = mqttBrokerService.getConnectionFromClientID(mqttClientId) 171 | assert connection != null 172 | } 173 | 174 | then: "A subscription should not exist" 175 | conditions.eventually { 176 | assert client.topicConsumerMap.get(incorrectDataTopic1) == null // Consumer added and removed on failure 177 | assert !handler.connectionSubscriberInfoMap.containsKey(getTELTONIKA_DEVICE_IMEI()); 178 | } 179 | 180 | cleanup: "disconnect client from broker" 181 | client.disconnect() 182 | client.removeAllMessageConsumers() 183 | 184 | } 185 | 186 | def "the device connects to the MQTT broker to a data topic without an IMEI"() { 187 | 188 | 189 | when: "the device connects to the MQTT broker to a data topic without an IMEI" 190 | 191 | def incorrectDataTopic2 = "${Constants.MASTER_REALM}/${mqttClientId}/${TELTONIKA_DEVICE_TOKEN}/${TELTONIKA_DEVICE_RECEIVE_TOPIC}".toString(); 192 | client.connect(); 193 | client.addMessageConsumer(incorrectDataTopic2, { _ -> return }); 194 | 195 | then: "mqtt connection should exist" 196 | conditions.eventually { 197 | assert client.getConnectionStatus() == ConnectionStatus.CONNECTED 198 | def connection = mqttBrokerService.getConnectionFromClientID(mqttClientId) 199 | assert connection != null 200 | } 201 | 202 | then: "A subscription should not exist" 203 | // This works because I am expecting either "data" or "command" on the 5th token, but the 5th token does not exist in the Topic 204 | conditions.eventually { 205 | assert !handler.connectionSubscriberInfoMap.containsKey(getTELTONIKA_DEVICE_IMEI()); 206 | assert client.topicConsumerMap.get(incorrectDataTopic2) == null // Consumer added and removed on failure 207 | } 208 | cleanup: "disconnect client from broker" 209 | client.disconnect() 210 | client.removeAllMessageConsumers() 211 | } 212 | 213 | def "the device connects to the MQTT broker to a data topic without a RX or TX endpoint"() { 214 | 215 | when: "the device connects to the MQTT broker to a data topic without a RX or TX endpoint" 216 | 217 | def incorrectDataTopic3 = "${Constants.MASTER_REALM}/${mqttClientId}/${TELTONIKA_DEVICE_TOKEN}/${TELTONIKA_DEVICE_IMEI}".toString(); 218 | 219 | client.connect(); 220 | client.addMessageConsumer(incorrectDataTopic3, { _ -> return }); 221 | 222 | then: "mqtt connection should exist" 223 | conditions.eventually { 224 | assert client.getConnectionStatus() == ConnectionStatus.CONNECTED 225 | def connection = mqttBrokerService.getConnectionFromClientID(mqttClientId) 226 | assert connection != null 227 | } 228 | 229 | then: "A subscription should not exist" 230 | // This works because I am expecting either "data" or "command" on the 5th token, but the 5th token does not exist in the Topic 231 | conditions.eventually { 232 | assert client.topicConsumerMap.get(incorrectDataTopic3) == null // Consumer added and removed on failure 233 | assert !handler.connectionSubscriberInfoMap.containsKey(getTELTONIKA_DEVICE_IMEI()); 234 | } 235 | 236 | cleanup: "disconnect client from broker" 237 | client.disconnect() 238 | client.removeAllMessageConsumers(); 239 | } 240 | 241 | def "the device connects to the broker with a valid data topic"() { 242 | when: "the device connects to the MQTT broker to a data topic with a RX endpoint" 243 | 244 | def correctTopic1 = "${Constants.MASTER_REALM}/${mqttClientId}/${TELTONIKA_DEVICE_TOKEN}/${TELTONIKA_DEVICE_IMEI}/${TELTONIKA_DEVICE_RECEIVE_TOPIC}".toString(); 245 | 246 | client.connect(); 247 | 248 | then: "mqtt connection should exist" 249 | conditions.eventually { 250 | assert client.getConnectionStatus() == ConnectionStatus.CONNECTED 251 | def connection = mqttBrokerService.getConnectionFromClientID(mqttClientId) 252 | assert connection != null 253 | } 254 | client.addMessageConsumer(correctTopic1, { _ -> return }); 255 | 256 | then: "A subscription should exist" 257 | conditions.eventually { 258 | assert client.topicConsumerMap.get(correctTopic1) != null 259 | assert handler.connectionSubscriberInfoMap.containsKey(getTELTONIKA_DEVICE_IMEI()); 260 | } 261 | 262 | cleanup: "disconnect client from broker" 263 | client.disconnect() 264 | client.removeAllMessageConsumers(); 265 | } 266 | 267 | def "the device connects to the broker with valid data and command topics"() { 268 | when: "the device connects to the MQTT broker to a data topic with a valid data topic" 269 | 270 | def correctTopicData = "${Constants.MASTER_REALM}/${mqttClientId}/${TELTONIKA_DEVICE_TOKEN}/${TELTONIKA_DEVICE_IMEI}/${TELTONIKA_DEVICE_RECEIVE_TOPIC}".toString(); 271 | def correctTopicCommands = "${Constants.MASTER_REALM}/${mqttClientId}/${TELTONIKA_DEVICE_TOKEN}/${TELTONIKA_DEVICE_IMEI}/${TELTONIKA_DEVICE_SEND_TOPIC}".toString(); 272 | 273 | client.connect(); 274 | client.addMessageConsumer(correctTopicData, { _ -> return }); 275 | client.addMessageConsumer(correctTopicCommands, { _ -> return }); 276 | 277 | then: "mqtt connection should exist" 278 | conditions.eventually { 279 | assert client.getConnectionStatus() == ConnectionStatus.CONNECTED 280 | def connection = mqttBrokerService.getConnectionFromClientID(mqttClientId) 281 | assert connection != null 282 | } 283 | 284 | then: "Two subscriptions should exist" 285 | conditions.eventually { 286 | assert client.topicConsumerMap.get(correctTopicData) != null 287 | assert client.topicConsumerMap.get(correctTopicCommands) != null 288 | assert handler.connectionSubscriberInfoMap.size() == 1; 289 | assert handler.connectionSubscriberInfoMap.containsKey(getTELTONIKA_DEVICE_IMEI()); 290 | 291 | } 292 | 293 | cleanup: "disconnect client from broker" 294 | client.disconnect() 295 | client.removeAllMessageConsumers(); 296 | } 297 | 298 | def "the handler parses the payload correctly"(){ 299 | when: "the device connects to the MQTT broker to a data topic with a valid data topic" 300 | 301 | def correctTopicData = "${Constants.MASTER_REALM}/${mqttClientId}/${TELTONIKA_DEVICE_TOKEN}/${TELTONIKA_DEVICE_IMEI}/${TELTONIKA_DEVICE_RECEIVE_TOPIC}".toString(); 302 | 303 | client.connect(); 304 | client.addMessageConsumer(correctTopicData, { _ -> return }); 305 | 306 | then: "mqtt connection should exist" 307 | conditions.eventually { 308 | assert client.getConnectionStatus() == ConnectionStatus.CONNECTED 309 | def connection = mqttBrokerService.getConnectionFromClientID(mqttClientId) 310 | assert connection != null 311 | } 312 | 313 | then: "Two subscriptions should exist" 314 | conditions.eventually { 315 | assert client.topicConsumerMap.get(correctTopicData) != null 316 | assert handler.connectionSubscriberInfoMap.size() == 1; 317 | assert handler.connectionSubscriberInfoMap.containsKey(getTELTONIKA_DEVICE_IMEI()); 318 | 319 | } 320 | 321 | when: "A correct payload is sent to the handler" 322 | 323 | client.sendMessage(new MQTTMessage(correctTopicData, getClass().getResource("/teltonika/teltonikaValidPayload1.json").text)) 324 | 325 | then: "A new asset should be created" 326 | 327 | 328 | Asset asset; 329 | 330 | 331 | conditions.eventually { 332 | asset = assetStorageService.find(UniqueIdentifierGenerator.generateId(getTELTONIKA_DEVICE_IMEI())) 333 | assert asset != null; 334 | assert asset.getAttribute(VehicleAsset.IMEI).get().getValue().get() == (getTELTONIKA_DEVICE_IMEI()); 335 | //Make sure that it parsed the attributes, since there is an issue of parsing the FMC003.json file 336 | assert asset.getAttributes().size() > 5; 337 | 338 | } 339 | 340 | and: "with the correct values within the Asset" 341 | 342 | conditions.eventually { 343 | // We cannot check every single attribute, so we are going to use specific attributes that have various quirks in them. 344 | 345 | //TODO: Add more parameters, maybe some weird ones like hex, 346 | 347 | //Attribute External Voltage, AVL ID "66" 348 | Optional> externalVoltage = asset.getAttribute("66"); 349 | assert externalVoltage.isPresent(); 350 | 351 | Attribute retrievedVoltage = externalVoltage.get(); 352 | 353 | assert retrievedVoltage.getValue().isPresent(); 354 | TeltonikaParameter voltageParam = params.get("66") 355 | assert retrievedVoltage.getType() == ValueType.NUMBER; 356 | assert retrievedVoltage.getMeta().containsKey(MetaItemType.STORE_DATA_POINTS.getName()) 357 | assert retrievedVoltage.getMeta().containsKey(MetaItemType.LABEL.getName()) 358 | assert retrievedVoltage.getMeta().get(MetaItemType.LABEL.getName()).get().getValue().get() == voltageParam.propertyName 359 | 360 | assert retrievedVoltage.getValue(ValueType.NUMBER.getType()).get() == 11922 * Double.parseDouble(voltageParam.multiplier) 361 | } 362 | 363 | 364 | cleanup: "delete asset and disconnect client from broker" 365 | 366 | assetStorageService.delete([asset.getId()]); 367 | 368 | client.removeAllMessageConsumers() 369 | client.disconnect() 370 | } 371 | 372 | def "the handler sends a message when the send attribute is updated and the response is added to the correct attribute"() { 373 | when: "the device connects to the MQTT broker to a data topic with valid data and command topics" 374 | 375 | def correctTopicData = "${Constants.MASTER_REALM}/${mqttClientId}/${TELTONIKA_DEVICE_TOKEN}/${TELTONIKA_DEVICE_IMEI}/${TELTONIKA_DEVICE_RECEIVE_TOPIC}".toString(); 376 | def correctTopicCommands = "${Constants.MASTER_REALM}/${mqttClientId}/${TELTONIKA_DEVICE_TOKEN}/${TELTONIKA_DEVICE_IMEI}/${TELTONIKA_DEVICE_SEND_TOPIC}".toString(); 377 | 378 | String testCommandValue = "test"; 379 | String testResponseKey = "RSP" 380 | String testResponseValue = "test successful" 381 | 382 | Map cmdMap = new HashMap<>(); 383 | Map rspMap = Map.of(testResponseKey, testResponseValue) 384 | 385 | Boolean correctResponse = false; 386 | client.connect(); 387 | client.addMessageConsumer(correctTopicData, { _ -> return }); 388 | client.addMessageConsumer(correctTopicCommands, { msg -> { 389 | getLOG().error("RECEIVED NEW MESSAGE: "+msg.getPayload()) 390 | ValueUtil.parse(msg.getPayload(), Map.class).ifPresent {map -> 391 | cmdMap = map 392 | getLOG().error("PARSED PAYLOAD" + cmdMap); 393 | } 394 | }}); 395 | 396 | then: "mqtt connection should exist" 397 | conditions.eventually { 398 | assert client.getConnectionStatus() == ConnectionStatus.CONNECTED 399 | def connection = mqttBrokerService.getConnectionFromClientID(mqttClientId) 400 | assert connection != null 401 | } 402 | 403 | then: "Two subscriptions should exist" 404 | conditions.eventually { 405 | assert client.topicConsumerMap.get(correctTopicData) != null 406 | assert client.topicConsumerMap.get(correctTopicCommands) != null 407 | assert handler.connectionSubscriberInfoMap.size() == 1; 408 | assert handler.connectionSubscriberInfoMap.containsKey(getTELTONIKA_DEVICE_IMEI()); 409 | 410 | } 411 | 412 | when: "A correct payload is sent to the handler" 413 | client.sendMessage(new MQTTMessage(correctTopicData, getClass().getResource("/teltonika/teltonikaValidPayload1.json").text)) 414 | 415 | then: "A new asset should be created" 416 | Asset asset; 417 | conditions.eventually { 418 | asset = assetStorageService.find(UniqueIdentifierGenerator.generateId(getTELTONIKA_DEVICE_IMEI())) 419 | assert asset != null; 420 | assert asset.getAttribute(VehicleAsset.IMEI).get().getValue().get() == (getTELTONIKA_DEVICE_IMEI()); 421 | } 422 | 423 | when: "the send message attribute is created and then updated" 424 | AttributeRef ref = new AttributeRef( 425 | asset.getId(), 426 | getTELTONIKA_DEVICE_SEND_COMMAND_ATTRIBUTE_NAME() 427 | ) 428 | 429 | 430 | asset.addAttributes(new Attribute(ref.getName(), ValueType.TEXT)); 431 | assetStorageService.merge(asset); 432 | 433 | assetProcessingService.sendAttributeEvent(new AttributeEvent(ref, testCommandValue)); 434 | 435 | then: "A message is sent to the device with the correct format" 436 | 437 | conditions.eventually { 438 | getLOG().info("eventually: "+cmdMap.toString()) 439 | assert cmdMap.containsKey("CMD") 440 | assert cmdMap.get("CMD") == testCommandValue 441 | } 442 | 443 | when: "the device replies to that message" 444 | 445 | // Check AWS IoT Core tutorial from Teltonika, the bottom of the page always shows that rspMap is the format we nee 446 | client.sendMessage(new MQTTMessage(correctTopicData, ValueUtil.asJSON(rspMap).get())); 447 | 448 | then: "The Handler understands the message and updates the response attribute" 449 | 450 | conditions.eventually { 451 | asset = assetStorageService.find(asset.getId()) 452 | Optional> attr = asset.getAttribute(getTELTONIKA_DEVICE_RECEIVE_COMMAND_ATTRIBUTE_NAME()); 453 | assert attr.isPresent(); 454 | assert attr.get().getValue(String.class).get() == testResponseValue 455 | } 456 | 457 | 458 | cleanup: "delete asset and disconnect client from broker" 459 | 460 | boolean result = assetStorageService.delete([asset.getId()]); 461 | 462 | conditions.eventually { 463 | result 464 | } 465 | 466 | client.removeAllMessageConsumers() 467 | client.disconnect() 468 | } 469 | 470 | 471 | //TODO: Commented-out test for now, need to create a concise list of payloads for full integration test, with 472 | // Asset state duration, bidirectional messages, etc. 473 | 474 | 475 | def "the handler stores all attributes with the correct timestamp"() { 476 | // when: "remove the asset, if it exists" 477 | // then: "asset is not there" 478 | // conditions.eventually { 479 | // assert assetStorageService.delete([UniqueIdentifierGenerator.generateId(TELTONIKA_DEVICE_IMEI)]) 480 | // assert assetStorageService.find(UniqueIdentifierGenerator.generateId(TELTONIKA_DEVICE_IMEI)) == null 481 | // } 482 | 483 | when: "the device connects to the MQTT broker to a data topic with a RX endpoint" 484 | 485 | String correctTopic1 = "${Constants.MASTER_REALM}/${mqttClientId}/${TELTONIKA_DEVICE_TOKEN}/${TELTONIKA_DEVICE_IMEI}/${TELTONIKA_DEVICE_RECEIVE_TOPIC}".toString(); 486 | client.connect(); 487 | then: "mqtt connection should exist" 488 | conditions.eventually { 489 | assert client.getConnectionStatus() == ConnectionStatus.CONNECTED 490 | def connection = mqttBrokerService.getConnectionFromClientID(mqttClientId) 491 | assert connection != null 492 | } 493 | when: "a client subscribes to the correct data topic" 494 | client.addMessageConsumer(correctTopic1, { _ -> return }); 495 | 496 | then: "A subscription should exist" 497 | conditions.eventually { 498 | assert client.topicConsumerMap.get(correctTopic1) != null 499 | assert handler.connectionSubscriberInfoMap.containsKey(getTELTONIKA_DEVICE_IMEI()); 500 | } 501 | 502 | when: "the JSON with all the payloads is parsed" 503 | def slurp = new JsonSlurper() 504 | ArrayList payloads = slurp.parseText(getClass().getResource("/teltonika/SortedPayloads.json").text) as ArrayList; 505 | then: "Assert that the payloads are correct" 506 | 507 | String assetId = UniqueIdentifierGenerator.generateId(getTELTONIKA_DEVICE_IMEI()); 508 | int i = 0; 509 | when: "the device starts publishing payloads" 510 | payloads.stream().limit(100).forEach { Object payload -> 511 | // Your logic here, for example: 512 | getLOG().debug(JsonOutput.toJson(payload)); 513 | 514 | client.sendMessage(new MQTTMessage(correctTopic1, JsonOutput.toJson(payload))) 515 | 516 | conditions.eventually { 517 | if(i > 0){ 518 | long payloadTimestamp = payload['state']['reported']['ts'] as long; 519 | int payloadElements = payload['state']['reported'].collect().size(); 520 | Asset asset = assetStorageService.find(new AssetQuery().ids(assetId).types(VehicleAsset.class)) 521 | assert asset.getAttribute(VehicleAsset.LAST_CONTACT).get().getValue().get().getTime() == payload['state']['reported']['ts'] 522 | 523 | //Check if the payload creates an equal amount of AttributeEvents 524 | assert asset.getAttributes().stream().filter ({a -> a.getTimestamp().get() == payloadTimestamp }).filter(a -> a.getType() != CustomValueTypes.ASSET_STATE_DURATION).count() == payloadElements 525 | } 526 | } 527 | i++; 528 | 529 | sleep(100); 530 | } 531 | then: "it's done" 532 | sleep(100) 533 | 534 | 535 | 536 | cleanup: "disconnect client from broker" 537 | client.disconnect() 538 | client.removeAllMessageConsumers(); 539 | 540 | 541 | } 542 | 543 | //TODO: Write a test for AssetStateDuration (Multiple trip payloads etc.) 544 | } -------------------------------------------------------------------------------- /test/src/test/resources/teltonika/teltonikaValidPayload1.json: -------------------------------------------------------------------------------- 1 | { 2 | "state":{ 3 | "reported":{ 4 | "ts":1698922776030, 5 | "pr":1, 6 | "latlng":"0.000000,0.000000", 7 | "alt":0, 8 | "ang":0, 9 | "sat":0, 10 | "sp":0, 11 | "evt":250, 12 | "239":0, 13 | "240":1, 14 | "80":1, 15 | "21":4, 16 | "200":0, 17 | "69":2, 18 | "237":2, 19 | "113":100, 20 | "263":2, 21 | "303":1, 22 | "250":1, 23 | "181":0, 24 | "182":0, 25 | "66":11922, 26 | "24":0, 27 | "206":30, 28 | "67":4110, 29 | "68":0, 30 | "13":2840, 31 | "17":45, 32 | "18":-11, 33 | "19":988, 34 | "15":1000, 35 | "241":20416, 36 | "199":0, 37 | "16":6870, 38 | "12":244, 39 | "449":0, 40 | "636":53977651, 41 | "11":893116211, 42 | "238":"0x0000000000000000", 43 | "14":1680671168, 44 | "387":"+000000.0000+0000000.0000+000.000/" 45 | } 46 | } 47 | } 48 | --------------------------------------------------------------------------------