├── frontend ├── .gitignore ├── src │ ├── types │ │ └── index.d.ts │ ├── red.png │ ├── green.png │ ├── battery.png │ ├── index.html │ ├── style.css │ └── index.ts ├── static │ ├── marker.png │ └── battery.png ├── Dockerfile ├── .babelrc ├── .releaserc ├── .eslintrc ├── tsconfig.json ├── jest.config.js ├── .env ├── webpack.prod.js ├── nginx.conf ├── webpack.dev.js ├── package.json ├── webpack.common.js ├── CONTRIBUTING.md └── LICENSE ├── project ├── build.properties └── plugins.sbt ├── src ├── main │ ├── resources │ │ ├── db │ │ │ └── migration │ │ │ │ ├── V2__add_accuracy.sql │ │ │ │ └── V1__create_positions.sql │ │ ├── logback.xml │ │ └── application.conf │ └── scala │ │ └── nl │ │ └── pragmasoft │ │ └── catracker │ │ ├── package.scala │ │ ├── Model.scala │ │ ├── Config.scala │ │ ├── KpnEvent.scala │ │ ├── Database.scala │ │ ├── Main.scala │ │ ├── ApiHandler.scala │ │ └── Tracker.scala └── test │ ├── resources │ ├── kpn.json │ ├── test.conf │ ├── kpn-2.json │ ├── application.conf │ ├── http-ttn-dragino.json │ ├── http-ttn.json │ └── sample.json │ └── scala │ └── nl │ └── pragmasoft │ └── catracker │ ├── MainTrackerSimulator.scala │ └── ParserSpec.scala ├── .gitignore ├── README.md ├── Dockerfile ├── .scalafmt.conf ├── ttn-decoders ├── tabs.js ├── dragino.js └── newttn.json ├── deployment.yaml └── api.yaml /frontend/.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | public/ 3 | #.env -------------------------------------------------------------------------------- /frontend/src/types/index.d.ts: -------------------------------------------------------------------------------- 1 | declare module '*.png'; -------------------------------------------------------------------------------- /project/build.properties: -------------------------------------------------------------------------------- 1 | sbt.version=1.4.9 2 | 3 | -------------------------------------------------------------------------------- /frontend/src/red.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jacum/catracker/HEAD/frontend/src/red.png -------------------------------------------------------------------------------- /frontend/src/green.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jacum/catracker/HEAD/frontend/src/green.png -------------------------------------------------------------------------------- /frontend/src/battery.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jacum/catracker/HEAD/frontend/src/battery.png -------------------------------------------------------------------------------- /frontend/static/marker.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jacum/catracker/HEAD/frontend/static/marker.png -------------------------------------------------------------------------------- /frontend/static/battery.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jacum/catracker/HEAD/frontend/static/battery.png -------------------------------------------------------------------------------- /frontend/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM eblovich/http 2 | COPY /public /files 3 | COPY /static /files 4 | COPY nginx.conf /etc/nginx/nginx.conf 5 | 6 | -------------------------------------------------------------------------------- /src/main/resources/db/migration/V2__add_accuracy.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE positions ADD COLUMN `accuracy` TINYINT DEFAULT 0 NOT NULL AFTER `bestSNR`; 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | **/target 2 | *.class 3 | *.log 4 | /.bsp/ 5 | target 6 | .idea 7 | 8 | .sbt.ivy.lock 9 | cache 10 | node_modules 11 | public 12 | -------------------------------------------------------------------------------- /src/test/resources/kpn.json: -------------------------------------------------------------------------------- 1 | [{"bn":"urn:dev:DEVEUI:E8E1E10001060A56:","bt":1.618061599E9},{"n":"payload","vs":"0bfe340000000000000000"},{"n":"port","v":136.0}] -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Full-stack type-safe application for The Things Network integration with GPS trackers 2 | 3 | Typescript (frontend) and Scala (backend), to collect data from trackers using TTN HTTP integration. 4 | 5 | -------------------------------------------------------------------------------- /src/test/resources/test.conf: -------------------------------------------------------------------------------- 1 | database { 2 | driver = "org.h2.Driver" 3 | url = "jdbc:h2:/tmp/catracker;MODE=MySQL;DATABASE_TO_LOWER=TRUE;DB_CLOSE_DELAY=-1" 4 | user = "sa" 5 | password = "" 6 | thread-pool-size = 32 7 | } 8 | -------------------------------------------------------------------------------- /src/main/scala/nl/pragmasoft/catracker/package.scala: -------------------------------------------------------------------------------- 1 | package nl.pragmasoft 2 | 3 | package object catracker { 4 | case class ConnectionId(value: String) extends AnyVal 5 | case class DeviceId(value: String) extends AnyVal 6 | } 7 | -------------------------------------------------------------------------------- /src/test/resources/kpn-2.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "bn": "urn:dev:DEVEUI:E8E1E10001060A56:", 4 | "bt": 1618049415 5 | }, 6 | { 7 | "n": "payload", 8 | "vs": "09CE33BF851E03D9704B40" 9 | }, 10 | { 11 | "n": "port", 12 | "v": 136 13 | } 14 | ] 15 | -------------------------------------------------------------------------------- /frontend/.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | [ 4 | "@babel/env", 5 | { 6 | "targets": { 7 | "browsers": "ie>=11, > 0.25%, not dead" 8 | }, 9 | "corejs": "3.6", 10 | "useBuiltIns": "usage" 11 | } 12 | ] 13 | ] 14 | } 15 | -------------------------------------------------------------------------------- /frontend/.releaserc: -------------------------------------------------------------------------------- 1 | branches: 2 | - main 3 | plugins: 4 | - "@semantic-release/commit-analyzer" 5 | - "@semantic-release/release-notes-generator" 6 | - "@semantic-release/git" 7 | - "@semantic-release/github" 8 | - "@semantic-release/npm" 9 | options: 10 | debug: true 11 | npm_token: "notoken" 12 | -------------------------------------------------------------------------------- /src/main/resources/logback.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | %d{HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /frontend/.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | "eslint:recommended", 4 | "plugin:prettier/recommended", 5 | "plugin:@typescript-eslint/recommended" 6 | ], 7 | "parser": "@typescript-eslint/parser", 8 | "parserOptions": { 9 | "ecmaVersion": 11, 10 | "sourceType": "module" 11 | }, 12 | "plugins": ["@typescript-eslint"], 13 | "globals": { "google": "readonly" }, 14 | "env": { 15 | "browser": true 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM oracle/graalvm-ce:20.3.0-java11 2 | USER root 3 | RUN id -u demiourgos728 1>/dev/null 2>&1 || (( getent group 0 1>/dev/null 2>&1 || ( type groupadd 1>/dev/null 2>&1 && groupadd -g 0 root || addgroup -g 0 -S root )) && ( type useradd 1>/dev/null 2>&1 && useradd --system --create-home --uid 1001 --gid 0 demiourgos728 || adduser -S -u 1001 -G root demiourgos728 )) 4 | COPY target/native-image/root /opt/ 5 | RUN ["chmod", "u+x,g+x", "/opt/root"] 6 | EXPOSE 8081 7 | USER 1001:0 8 | ENTRYPOINT ["/opt/root"] 9 | CMD [] 10 | -------------------------------------------------------------------------------- /src/main/resources/db/migration/V1__create_positions.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE positions ( 2 | `id` SERIAL PRIMARY KEY, 3 | `recorded` BIGINT not null, 4 | `latitude` double NOT NULL DEFAULT 0, 5 | `longitude` double NOT NULL DEFAULT 0, 6 | `battery` TINYINT NOT NULL, 7 | `temperature` TINYINT NOT NULL, 8 | `app` VARCHAR(100), 9 | `deviceType` VARCHAR(100), 10 | `deviceSerial` VARCHAR(100), 11 | `positionFix` tinyint(1) NOT NULL, 12 | `bestGateway` VARCHAR(100), 13 | `bestSNR` double NOT NULL DEFAULT 0, 14 | `counter` MEDIUMINT NOT NULL, 15 | INDEX(recorded), 16 | INDEX(deviceSerial) 17 | ) ENGINE=InnoDB DEFAULT CHARSET=utf8; 18 | 19 | -------------------------------------------------------------------------------- /frontend/src/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | caTracker 5 | 6 | 10 | 11 | 12 | 13 | 14 |
15 |
16 |
17 |
18 |
19 | 20 | 21 | 22 | -------------------------------------------------------------------------------- /src/main/scala/nl/pragmasoft/catracker/Model.scala: -------------------------------------------------------------------------------- 1 | package nl.pragmasoft.catracker 2 | 3 | object Model { 4 | 5 | case class StoredPosition( 6 | id: Long = 0L, 7 | recorded: Long, 8 | app: String, 9 | deviceType: String, 10 | deviceSerial: String, 11 | latitude: BigDecimal, 12 | longitude: BigDecimal, 13 | positionFix: Boolean, 14 | bestGateway: String, 15 | bestSNR: BigDecimal, 16 | battery: Int, 17 | accuracy: Int, 18 | temperature: Int, 19 | counter: Long 20 | ) 21 | 22 | trait PositionRepository[F[_]] { 23 | def add(p: StoredPosition): F[Unit] 24 | def findForDevice(deviceSerial: String): F[List[StoredPosition]] 25 | } 26 | 27 | } 28 | -------------------------------------------------------------------------------- /frontend/src/style.css: -------------------------------------------------------------------------------- 1 | #map { 2 | height: 100%; 3 | } 4 | 5 | html, 6 | body { 7 | height: 100%; 8 | margin: 0; 9 | padding: 0; 10 | } 11 | 12 | #panel { position: absolute; 13 | top: 80px; left: 50px; font-size: 30px; z-index: 99; vertical-align: middle; } 14 | 15 | #battery { background: "/battery.png"; text-align: center; height: 80px; line-height: 80px; width: 80px; display: inline-block; 16 | background-repeat: no-repeat; background-position: center; background-size: contain; vertical-align: middle; } 17 | 18 | #connection { background: url(red.png); width: 80px; display: inline-block; 19 | background-repeat: no-repeat; background-position: center; background-size: contain; vertical-align: middle; } 20 | 21 | -------------------------------------------------------------------------------- /frontend/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "esnext", 4 | "outDir": "dist/", 5 | "noImplicitAny": true, 6 | "sourceMap": true, 7 | "target": "ESNext", 8 | "allowJs": true, 9 | "checkJs": false, 10 | "pretty": true, 11 | "skipLibCheck": true, 12 | "strict": true, 13 | "moduleResolution": "node", 14 | "esModuleInterop": true, 15 | "lib": ["dom", "dom.iterable", "ESNext"], 16 | "allowSyntheticDefaultImports": true, 17 | "forceConsistentCasingInFileNames": true, 18 | "resolveJsonModule": true, 19 | "isolatedModules": true, 20 | "types": ["googlemaps"], 21 | "typeRoots" : ["node_modules/@types", "src/types"] 22 | }, 23 | "include": ["src"], 24 | "exclude": ["node_modules", "**/*.spec.ts"] 25 | } 26 | -------------------------------------------------------------------------------- /frontend/jest.config.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright 2020 Google LLC 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | module.exports = { 18 | transform: { 19 | "^.+\\.tsx?$": "ts-jest", 20 | }, 21 | collectCoverage: true, 22 | }; 23 | -------------------------------------------------------------------------------- /src/main/scala/nl/pragmasoft/catracker/Config.scala: -------------------------------------------------------------------------------- 1 | package nl.pragmasoft.catracker 2 | 3 | import cats.effect.{Blocker, ContextShift, IO, Resource} 4 | import com.typesafe.config.ConfigFactory 5 | import nl.pragmasoft.catracker.Config.DatabaseConfig 6 | import pureconfig._ 7 | import pureconfig.generic.auto._ 8 | import pureconfig.module.catseffect.syntax._ 9 | 10 | case class Config(database: DatabaseConfig) 11 | 12 | object Config { 13 | 14 | case class DatabaseConfig(driver: String, url: String, user: String, password: String, threadPoolSize: Int) 15 | 16 | def load(configFile: String = "application.conf")(implicit cs: ContextShift[IO]): Resource[IO, Config] = 17 | Blocker[IO].flatMap { blocker => 18 | Resource.liftF(ConfigSource.fromConfig(ConfigFactory.load(configFile)).loadF[IO, Config](blocker)) 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /.scalafmt.conf: -------------------------------------------------------------------------------- 1 | version = 2.6.4 2 | 3 | maxColumn = 180 4 | docstrings = JavaDoc 5 | importSelectors = singleLine 6 | style = defaultWithAlign 7 | align.openParenCallSite = false 8 | align.openParenDefnSite = false 9 | continuationIndent.callSite = 2 10 | continuationIndent.defnSite = 2 11 | //danglingParentheses = true 12 | //indentOperator = spray 13 | newlines.alwaysBeforeTopLevelStatements = false 14 | newlines.alwaysBeforeElseAfterCurlyIf = false 15 | spaces.inImportCurlyBraces = false 16 | unindentTopLevelOperators = true 17 | rewrite.rules = [ 18 | RedundantParens, 19 | RedundantBraces, 20 | PreferCurlyFors, 21 | AsciiSortImports, 22 | SortModifiers 23 | ] 24 | rewrite.sortModifiers.order = [ 25 | "private", "protected", "implicit", "final", "sealed", "abstract", 26 | "override", "lazy" 27 | ] 28 | 29 | project.excludeFilters = [] 30 | 31 | includeCurlyBraceInSelectChains = false 32 | 33 | -------------------------------------------------------------------------------- /frontend/.env: -------------------------------------------------------------------------------- 1 | # Copyright 2020 Google LLC 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | # NOTE Variables set in this file do not override values set on the machine. 16 | 17 | # TODO Replace the low quota, restricted key below 18 | # https://developers.devsite.corp.google.com/maps/documentation/javascript/get-api-key 19 | GOOGLE_MAPS_API_KEY=AIzaSyDhTXtuZyWDrfTtWvdmnnAGjAU18zHGiNo 20 | -------------------------------------------------------------------------------- /frontend/webpack.prod.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright 2020 Google LLC 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | const TerserPlugin = require("terser-webpack-plugin"); 18 | const common = require("./webpack.common.js"); 19 | const { merge } = require("webpack-merge"); 20 | 21 | module.exports = merge(common, { 22 | mode: "production", 23 | optimization: { 24 | minimizer: [new TerserPlugin()] 25 | } 26 | }); 27 | -------------------------------------------------------------------------------- /project/plugins.sbt: -------------------------------------------------------------------------------- 1 | import sbt.addSbtPlugin 2 | addSbtPlugin("com.typesafe.sbt" % "sbt-git" % "1.0.0") 3 | addSbtPlugin("no.arktekk.sbt" % "aether-deploy" % "0.26.0") 4 | addSbtPlugin("com.twilio" % "sbt-guardrail" % "0.62.0") 5 | addSbtPlugin("org.wartremover" % "sbt-wartremover" % "2.4.13") 6 | addSbtPlugin("net.vonbuchholtz" % "sbt-dependency-check" % "3.0.0") 7 | addSbtPlugin("com.typesafe.sbt" % "sbt-native-packager" % "1.8.0") 8 | addSbtPlugin("org.scalameta" % "sbt-native-image" % "0.2.2") 9 | addSbtPlugin("com.timushev.sbt" % "sbt-updates" % "0.5.1") 10 | addSbtPlugin("org.scalameta" % "sbt-scalafmt" % "2.4.2") 11 | addSbtPlugin("org.xerial.sbt" % "sbt-sonatype" % "3.9.5") 12 | addSbtPlugin("com.jsuereth" % "sbt-pgp" % "2.1.1") 13 | addSbtPlugin("no.arktekk.sbt" % "aether-deploy" % "0.26.0") 14 | addSbtPlugin("com.github.gseitz" % "sbt-release" % "1.0.13") 15 | addSbtPlugin("org.scoverage" % "sbt-scoverage" % "1.6.1") 16 | addSbtPlugin("com.twilio" % "sbt-guardrail" % "0.60.0") 17 | addSbtPlugin("com.eed3si9n" % "sbt-assembly" % "0.15.0") -------------------------------------------------------------------------------- /src/test/resources/application.conf: -------------------------------------------------------------------------------- 1 | database { 2 | driver = "com.mysql.cj.jdbc.Driver" 3 | url = "jdbc:mysql://localhost/catracker" 4 | user = "root" 5 | password = "" 6 | password = ${?DATABASE_PASSWORD} 7 | thread-pool-size = 32 8 | } 9 | 10 | akka { 11 | log-config-on-start = off 12 | jvm-exit-on-fatal-error = true 13 | loglevel = "INFO" 14 | loggers = ["akka.event.slf4j.Slf4jLogger"] 15 | logging-filter = "akka.event.slf4j.Slf4jLoggingFilter" 16 | 17 | // No need to see the java serialization warnings for the tests in this module 18 | actor.allow-java-serialization = on 19 | actor.warn-about-java-serializer-usage = off 20 | 21 | // actor.serialize-messages = on 22 | actor.serialize-creators = off 23 | 24 | persistence.journal.plugin = "inmemory-journal" 25 | persistence.snapshot-store.plugin = "" 26 | 27 | } 28 | 29 | inmemory-read-journal { 30 | write-plugin = "inmemory-journal" 31 | offset-mode = "sequence" 32 | ask-timeout = "10s" 33 | refresh-interval = "50ms" 34 | max-buffer-size = "100" 35 | } 36 | 37 | -------------------------------------------------------------------------------- /src/test/resources/http-ttn-dragino.json: -------------------------------------------------------------------------------- 1 | { 2 | "app_id": "pragma_cats_dragino", 3 | "dev_id": "dragino_test1", 4 | "hardware_serial": "A840416B61826E5F", 5 | "port": 2, 6 | "counter": 93, 7 | "payload_raw": "Ax6FtABLdC0OZWM=", 8 | "payload_fields": { 9 | "accuracy": 0, 10 | "capacity": 0, 11 | "latitude": 52.331956, 12 | "longitude": 4.944941, 13 | "port": 2, 14 | "temperature": 0, 15 | "voltage": 3.685, 16 | "gnss_fix": false 17 | }, 18 | "metadata": { 19 | "time": "2020-12-28T11:36:17.269018381Z", 20 | "frequency": 868.1, 21 | "modulation": "LORA", 22 | "data_rate": "SF7BW125", 23 | "coding_rate": "4/5", 24 | "gateways": [ 25 | { 26 | "gtw_id": "eui-58a0cbfffe802a34", 27 | "timestamp": 13398483, 28 | "time": "2020-12-28T11:36:17.480709075Z", 29 | "channel": 0, 30 | "rssi": -87, 31 | "snr": 9.75, 32 | "rf_chain": 0 33 | } 34 | ] 35 | }, 36 | "downlink_url": "https://integrations.thethingsnetwork.org/ttn-eu/api/v2/down/pragma_cats_dragino/catracker?key=ttn-account-v2.q3vCLU1Une4Z7lxiSy3P1ZG8cBfaxQB66AbnL02aHNg" 37 | } -------------------------------------------------------------------------------- /src/main/resources/application.conf: -------------------------------------------------------------------------------- 1 | database { 2 | driver = "com.mysql.cj.jdbc.Driver" 3 | 4 | // url = "jdbc:mysql://localhost/catracker" 5 | // user = "root" 6 | // password = "" 7 | // 8 | url = "jdbc:mysql://mariadb/catracker" 9 | user = "catracker" 10 | password = ${DATABASE_PASSWORD} 11 | 12 | thread-pool-size = 32 13 | } 14 | 15 | akka { 16 | log-config-on-start = off 17 | jvm-exit-on-fatal-error = true 18 | loglevel = "INFO" 19 | loggers = ["akka.event.slf4j.Slf4jLogger"] 20 | logging-filter = "akka.event.slf4j.Slf4jLoggingFilter" 21 | 22 | // No need to see the java serialization warnings for the tests in this module 23 | actor.allow-java-serialization = on 24 | actor.warn-about-java-serializer-usage = off 25 | 26 | // actor.serialize-messages = on 27 | actor.serialize-creators = off 28 | 29 | persistence.journal.plugin = "inmemory-journal" 30 | persistence.snapshot-store.plugin = "" 31 | 32 | } 33 | 34 | inmemory-read-journal { 35 | write-plugin = "inmemory-journal" 36 | offset-mode = "sequence" 37 | ask-timeout = "10s" 38 | refresh-interval = "50ms" 39 | max-buffer-size = "100" 40 | } 41 | 42 | -------------------------------------------------------------------------------- /frontend/nginx.conf: -------------------------------------------------------------------------------- 1 | worker_processes 3; 2 | error_log stderr; 3 | pid /var/cache/nginx/nginx.pid; 4 | 5 | events { 6 | worker_connections 10240; 7 | } 8 | 9 | http { 10 | include mime.types; 11 | 12 | log_format main '$http_x_real_ip - [$time_local] "$request" $status $body_bytes_sent "$http_referer" "$http_user_agent" $request_time $upstream_response_time'; 13 | 14 | access_log /dev/stdout main; 15 | 16 | server { 17 | listen 8080; 18 | server_name _; 19 | root /files; 20 | 21 | location /api { 22 | add_header Cache-Control no-cache; 23 | proxy_set_header Host $host; 24 | proxy_set_header X-Real-IP $http_x_real_ip; 25 | proxy_pass http://127.0.0.1:8081; 26 | } 27 | 28 | location /api/catracker/ws { 29 | proxy_pass http://127.0.0.1:8081; 30 | proxy_http_version 1.1; 31 | proxy_set_header Upgrade $http_upgrade; 32 | proxy_set_header Connection "Upgrade"; 33 | proxy_set_header Host $host; 34 | } 35 | 36 | location / { 37 | try_files $uri /index.html; 38 | } 39 | 40 | location /index.html { 41 | add_header Cache-Control no-cache; 42 | } 43 | 44 | } 45 | } -------------------------------------------------------------------------------- /frontend/webpack.dev.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright 2020 Google LLC 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | const path = require("path"); 17 | const common = require("./webpack.common.js"); 18 | const { merge } = require("webpack-merge"); 19 | 20 | module.exports = merge(common, { 21 | mode: "development", 22 | watch: true, 23 | devtool: "inline-source-map", 24 | devServer: { 25 | contentBase: path.resolve(__dirname, "public"), 26 | liveReload: true, 27 | host: "0.0.0.0", 28 | port: 8080, 29 | historyApiFallback: true, 30 | writeToDisk: true, 31 | disableHostCheck: true, 32 | }, 33 | }); 34 | -------------------------------------------------------------------------------- /src/test/resources/http-ttn.json: -------------------------------------------------------------------------------- 1 | { 2 | "app_id": "pragma_cats_tabs", 3 | "dev_id": "tabs_test1", 4 | "hardware_serial": "58A0CB0000204688", 5 | "port": 136, 6 | "counter": 85, 7 | "payload_raw": "CO4y0IUeA3hxS0A=", 8 | "payload_fields": { 9 | "accuracy": 16, 10 | "bytes": "CO4y0IUeA3hxS0A=", 11 | "capacity": 93.33333333333333, 12 | "gnss_fix": false, 13 | "latitude": 52.331984, 14 | "longitude": 4.944248, 15 | "port": 136, 16 | "temperature": 18, 17 | "voltage": 3.9 18 | }, 19 | "metadata": { 20 | "time": "2020-12-25T12:07:49.102008218Z", 21 | "frequency": 868.5, 22 | "modulation": "LORA", 23 | "data_rate": "SF12BW125", 24 | "coding_rate": "4/5", 25 | "gateways": [ 26 | { 27 | "gtw_id": "eui-58a0cbfffe802a34", 28 | "timestamp": 48747028, 29 | "time": "2020-12-25T12:07:49.168767929Z", 30 | "channel": 0, 31 | "rssi": -68, 32 | "snr": 6.5, 33 | "rf_chain": 0 34 | } 35 | ] 36 | }, 37 | "downlink_url": "https://integrations.thethingsnetwork.org/ttn-eu/api/v2/down/pragma_cats_tabs/requestbin?key=ttn-account-v2.yG7tTCBkMQ8Ktg35n6rBsiUEGPIWLdf36mW_v_Mfwp8" 38 | } -------------------------------------------------------------------------------- /ttn-decoders/tabs.js: -------------------------------------------------------------------------------- 1 | function Decoder(bytes, port) { 2 | var params = { 3 | "bytes": bytes 4 | }; 5 | 6 | bytes = bytes.slice(bytes.length-11); 7 | 8 | if ((bytes[0] & 0x8) === 0) { 9 | params.gnss_fix = true; 10 | } else { 11 | params.gnss_fix = false; 12 | } 13 | 14 | // Mask off enf of temp byte, RFU 15 | temp = bytes[2] & 0x7f; 16 | 17 | acc = bytes[10] >> 5; 18 | acc = Math.pow(2, parseInt(acc) + 2); 19 | 20 | // Mask off end of accuracy byte, so lon doesn't get affected 21 | bytes[10] &= 0x1f; 22 | 23 | if ((bytes[10] & (1 << 4)) !== 0) { 24 | bytes[10] |= 0xe0; 25 | } 26 | 27 | // Mask off end of lat byte, RFU 28 | bytes[6] &= 0x0f; 29 | 30 | lat = bytes[6] << 24 | bytes[5] << 16 | bytes[4] << 8 | bytes[3]; 31 | lon = bytes[10] << 24 | bytes[9] << 16 | bytes[8] << 8 | bytes[7]; 32 | 33 | battery = bytes[1]; 34 | capacity = battery >> 4; 35 | voltage = battery & 0x0f; 36 | 37 | params.latitude = lat/1000000; 38 | params.longitude = lon/1000000; 39 | params.accuracy = acc; 40 | params.temperature = temp - 32; 41 | params.capacity = (capacity / 15) * 100; 42 | params.voltage = (25 + voltage)/10; 43 | params.port=port; 44 | 45 | return params; 46 | 47 | } -------------------------------------------------------------------------------- /ttn-decoders/dragino.js: -------------------------------------------------------------------------------- 1 | function Decoder(bytes, port) { 2 | var value=bytes[0]<<24 | bytes[1]<<16 | bytes[2]<<8 | bytes[3]; 3 | if(bytes[0] & 0x80) { value |=0xFFFFFFFF00000000; } 4 | var latitude=value/1000000; 5 | value=bytes[4]<<24 | bytes[5]<<16 | bytes[6]<<8 | bytes[7]; 6 | if(bytes[4] & 0x80) { value |=0xFFFFFFFF00000000; } 7 | var longitude=value/1000000;//gps longitude,units: ° 8 | var alarm=(bytes[8] & 0x40)?"TRUE":"FALSE";//Alarm status 9 | value=((bytes[8] & 0x3f) <<8) | bytes[9]; 10 | var batV=value/1000;//Battery,units:V 11 | value=(bytes[10] & 0xC0); 12 | if(value==0x40) { var motion_mode="Move"; } 13 | else if(value==0x80) { motion_mode="Collide"; } 14 | else if(value==0xC0) { motion_mode="User"; } 15 | else { motion_mode="Disable"; } 16 | var led_updown=(bytes[10] & 0x20)?"ON":"OFF"; 17 | value=bytes[11]<<8 | bytes[12]; 18 | if(bytes[11] & 0x80) 19 | { 20 | value |=0xFFFF0000; 21 | } 22 | var roll=value/100;//roll,units: ° 23 | value=bytes[13]<<8 | bytes[14]; 24 | if(bytes[13] & 0x80) 25 | { 26 | value |=0xFFFF0000; 27 | } 28 | var pitch=value/100; //pitch,units: ° 29 | var params = {}; 30 | params.latitude = latitude; 31 | params.longitude = longitude; 32 | params.gnss_fix = (params.latitude + params.longitude) > 0; 33 | params.accuracy = 0; 34 | params.temperature = 0; 35 | params.capacity = 0; 36 | params.voltage = batV; 37 | params.port=port; 38 | 39 | return params; 40 | 41 | } -------------------------------------------------------------------------------- /src/main/scala/nl/pragmasoft/catracker/KpnEvent.scala: -------------------------------------------------------------------------------- 1 | package nl.pragmasoft.catracker 2 | 3 | import nl.pragmasoft.catracker.Model.StoredPosition 4 | import nl.pragmasoft.catracker.http.definitions.KpnEventRecord 5 | import org.apache.commons.codec.binary.Hex 6 | 7 | import java.nio.{ByteBuffer, ByteOrder} 8 | 9 | object KpnEvent { 10 | 11 | def decode(body: Vector[KpnEventRecord]): Option[StoredPosition] = 12 | for { 13 | header <- body.find(_.bn.isDefined) 14 | payload <- body.find(_.n.contains("payload")) 15 | } yield { 16 | val bytes = Hex.decodeHex(payload.vs.get.toCharArray) 17 | val byte10a: Byte = (bytes(10) & 0x1).toByte 18 | val byte10b: Byte = if ((byte10a & (1 << 4)) != 0) (byte10a | 0xe0).toByte else byte10a 19 | 20 | StoredPosition( 21 | recorded = header.bt.get.toLong * 1000, 22 | app = "kpn", 23 | deviceType = "kpn", 24 | deviceSerial = header.bn.get.split(':')(3), 25 | id = header.bt.get.toLong, 26 | latitude = ByteBuffer.wrap(Array[Byte](bytes(6), bytes(5), bytes(4), bytes(3))).order(ByteOrder.BIG_ENDIAN).getInt.toDouble / 1000000, 27 | longitude = ByteBuffer.wrap(Array[Byte](byte10b, bytes(9), bytes(8), bytes(7))).order(ByteOrder.BIG_ENDIAN).getInt.toDouble / 1000000, 28 | positionFix = (bytes(0) & 0x8) == 0, 29 | bestGateway = "", 30 | bestSNR = 0, 31 | battery = (((bytes(1) & 0x0f).toDouble / 15) * 100).toInt, 32 | accuracy = Math.pow(2, (bytes(10) >> 5) + 2).toInt, 33 | temperature = bytes(2) & 0x7f - 32, 34 | counter = 0 35 | ) 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /frontend/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "ts-webpack-googlemaps-sample", 3 | "scripts": { 4 | "build": "webpack --config ./webpack.prod.js --mode production", 5 | "dev": "webpack-dev-server --config ./webpack.dev.js", 6 | "format": "eslint --fix src/*.ts", 7 | "lint": "eslint src/*.ts", 8 | "test": "jest --passWithNoTests src/*", 9 | "watch": "webpack --config ./webpack.dev.js --mode development" 10 | }, 11 | "dependencies": { 12 | "request": "^2.88.2", 13 | "request-promise-native": "^1.0.9", 14 | "webpack-merge": "^5.2.0" 15 | }, 16 | "devDependencies": { 17 | "@babel/core": "^7.12.3", 18 | "@babel/preset-env": "^7.12.1", 19 | "@babel/runtime": "^7.12.1", 20 | "@types/googlemaps": "^3.40.0", 21 | "@types/jest": "^26.0.14", 22 | "@types/uuid": "^8.3.0", 23 | "@types/webpack-env": "^1.16.0", 24 | "@types/websocket": "^1.0.2", 25 | "@typescript-eslint/eslint-plugin": "^4.0.0", 26 | "@typescript-eslint/parser": "^3.10.1", 27 | "babel-loader": "^8.1.0", 28 | "css-loader": "^4.3.0", 29 | "dotenv": "^8.2.0", 30 | "eslint": "^7.11.0", 31 | "eslint-config-prettier": "^6.13.0", 32 | "eslint-plugin-prettier": "^3.1.4", 33 | "file-loader": "^6.2.0", 34 | "html-replace-webpack-plugin": "^2.5.6", 35 | "html-webpack-plugin": "^4.5.0", 36 | "jest": "^26.4.2", 37 | "mini-css-extract-plugin": "^0.11.3", 38 | "prettier": "^2.1.2", 39 | "terser-webpack-plugin": "^4.2.3", 40 | "ts-jest": "^26.4.1", 41 | "ts-loader": "^8.0.4", 42 | "typescript": "^4.0.3", 43 | "uuid": "^3.4.0", 44 | "webpack": "^4.44.2", 45 | "webpack-cli": "^3.3.12", 46 | "webpack-dev-server": "^3.11.0", 47 | "websocket-ts": "^1.1.0" 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /src/test/scala/nl/pragmasoft/catracker/MainTrackerSimulator.scala: -------------------------------------------------------------------------------- 1 | package nl.pragmasoft.catracker 2 | 3 | import cats.effect.{Async, ExitCode, IO, IOApp} 4 | import cats.implicits._ 5 | import com.typesafe.scalalogging.LazyLogging 6 | import nl.pragmasoft.catracker.http.client.{Client, IncomingEventTtnResponse} 7 | import nl.pragmasoft.catracker.http.client.definitions.TtnEvent 8 | import org.http4s.client.blaze.BlazeClientBuilder 9 | 10 | import java.time.{LocalDateTime, ZoneOffset} 11 | import scala.concurrent.ExecutionContext 12 | import scala.concurrent.duration.DurationInt 13 | import scala.util.Random 14 | 15 | object MainTrackerSimulator extends IOApp with LazyLogging { 16 | 17 | override def run(args: List[String]): IO[ExitCode] = 18 | BlazeClientBuilder[IO](ExecutionContext.global).resource.use { client => 19 | val ttnPoster = new Client[IO]("http://localhost:8081/api/catracker")(implicitly[Async[IO]], client) 20 | def send: IO[IncomingEventTtnResponse] = ttnPoster.incomingEventTtn(createTtnEvent) 21 | def repeat: IO[Unit] = send >> timer.sleep(10 seconds) >> repeat 22 | repeat 23 | }.as(ExitCode.Success) 24 | 25 | private def createTtnEvent: TtnEvent = 26 | TtnEvent( 27 | "pragma_cats_dragino", 28 | "dragino_test1", 29 | "A840416B61826E5F", 30 | 93, 31 | TtnEvent.PayloadFields(4, 60, true, 52.331956 + Random.between(-0.001, +0.001), 4.944941 + Random.between(-0.001, +0.001), Some(2), 0, 3.685), 32 | TtnEvent.Metadata( 33 | LocalDateTime.now().atOffset(ZoneOffset.UTC), 34 | 868.1, 35 | "LORA", 36 | "SF7BW125", 37 | "4/5", 38 | Vector(TtnEvent.Metadata.Gateways("eui-58a0cbfffe802a34", LocalDateTime.now().atOffset(ZoneOffset.UTC), 0, -87, 9.75)) 39 | ) 40 | ) 41 | } 42 | -------------------------------------------------------------------------------- /ttn-decoders/newttn.json: -------------------------------------------------------------------------------- 1 | { 2 | "end_device_ids": { 3 | "device_id": "tracker-001", 4 | "application_ids": { 5 | "application_id": "cat-tracker" 6 | }, 7 | "dev_eui": "E8E1E10001060F3E", 8 | "join_eui": "E8E1E10001013640", 9 | "dev_addr": "260B0A9D" 10 | }, 11 | "correlation_ids": [ 12 | "as:up:01GMJ5F26ATQGZD5AZDF2DWGRN", 13 | "gs:conn:01GMFW99RHK9NNS8NBKCJTYWKX", 14 | "gs:up:host:01GMFW99RRSW12EMWV359QWGX4", 15 | "gs:uplink:01GMJ5F1ZS9QX041VYAAXNKNNW", 16 | "ns:uplink:01GMJ5F1ZTC44ZG72MF234ZWVS", 17 | "rpc:/ttn.lorawan.v3.GsNs/HandleUplink:01GMJ5F1ZT3PCZYSMN1KFZFBM5", 18 | "rpc:/ttn.lorawan.v3.NsAs/HandleUplink:01GMJ5F269X4JVRWR98Y3V45G6" 19 | ], 20 | "received_at": "2022-12-18T08:26:34.570184942Z", 21 | "uplink_message": { 22 | "session_key_id": "AYUkRTQ8WqF2tb7DYB8PsA==", 23 | "f_port": 136, 24 | "f_cnt": 7, 25 | "frm_payload": "CVw1AAAAAAAAAAA=", 26 | "decoded_payload": { 27 | "accuracy": 4, 28 | "capacity": 33.33333333333333, 29 | "fix": false, 30 | "latitude": 0, 31 | "longitude": 0, 32 | "temperature": 21, 33 | "voltage": 3.7 34 | }, 35 | "rx_metadata": [ 36 | { 37 | "gateway_ids": { 38 | "gateway_id": "eui-58a0cbfffe802c19", 39 | "eui": "58A0CBFFFE802C19" 40 | }, 41 | "time": "2022-12-18T08:26:34.283977985Z", 42 | "timestamp": 3719025443, 43 | "rssi": -65, 44 | "channel_rssi": -65, 45 | "snr": 8.75, 46 | "uplink_token": "CiIKIAoUZXVpLTU4YTBjYmZmZmU4MDJjMTkSCFigy//+gCwZEKOmr+0NGgwIup37nAYQ3bGurAEguIHDuZ65EQ==", 47 | "received_at": "2022-12-18T08:26:34.310073949Z" 48 | } 49 | ], 50 | "settings": { 51 | "data_rate": { 52 | "lora": { 53 | "bandwidth": 125000, 54 | "spreading_factor": 7, 55 | "coding_rate": "4/5" 56 | } 57 | }, 58 | "frequency": "867300000", 59 | "timestamp": 3719025443, 60 | "time": "2022-12-18T08:26:34.283977985Z" 61 | }, 62 | "received_at": "2022-12-18T08:26:34.362715943Z", 63 | "consumed_airtime": "0.061696s", 64 | "network_ids": { 65 | "net_id": "000013", 66 | "tenant_id": "ttn", 67 | "cluster_id": "eu1", 68 | "cluster_address": "eu1.cloud.thethings.network" 69 | } 70 | } 71 | } -------------------------------------------------------------------------------- /frontend/webpack.common.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright 2020 Google LLC 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | require("dotenv").config({}); 18 | 19 | const webpack = require("webpack"); 20 | const HtmlWebpackPlugin = require("html-webpack-plugin"); 21 | const HtmlReplaceWebpackPlugin = require("html-replace-webpack-plugin"); 22 | const MiniCssExtractPlugin = require("mini-css-extract-plugin"); 23 | 24 | module.exports = { 25 | entry: ["./src/index.ts"], 26 | module: { 27 | rules: [ 28 | { 29 | test: /\.js$/i, 30 | exclude: /node_modules/, 31 | use: ["babel-loader"], 32 | }, 33 | { 34 | test: /\.ts$/i, 35 | use: "ts-loader", 36 | exclude: /node_modules/, 37 | }, 38 | { 39 | test: /\.css$/i, 40 | exclude: /node_modules/, 41 | use: [MiniCssExtractPlugin.loader, "css-loader"], 42 | 43 | }, 44 | { 45 | test: /\.png$/i, 46 | use: [ 47 | { 48 | loader: 'file-loader', 49 | }, 50 | ], 51 | } 52 | ], 53 | }, 54 | resolve: { 55 | extensions: [".ts", ".js"], 56 | }, 57 | output: { 58 | path: `${__dirname}/public`, 59 | filename: 'app.bundle.js', 60 | publicPath: `${__dirname}/public`, 61 | library: "", 62 | libraryTarget: "window", 63 | }, 64 | plugins: [ 65 | new webpack.DefinePlugin({ 66 | __DEV__: true, 67 | }), 68 | new HtmlWebpackPlugin({ 69 | template: "src/index.html", 70 | inject: false, 71 | }), 72 | new HtmlReplaceWebpackPlugin([ 73 | { 74 | pattern: "YOUR_API_KEY", 75 | replacement: process.env.GOOGLE_MAPS_API_KEY, 76 | }, 77 | ]), 78 | new MiniCssExtractPlugin({ 79 | filename: "style.css", 80 | }), 81 | ], 82 | }; 83 | -------------------------------------------------------------------------------- /frontend/CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # How to become a contributor and submit your own code 2 | 3 | **Table of contents** 4 | 5 | * [Contributor License Agreements](#contributor-license-agreements) 6 | * [Contributing a patch](#contributing-a-patch) 7 | * [Running the tests](#running-the-tests) 8 | * [Releasing the library](#releasing-the-library) 9 | 10 | ## Contributor License Agreements 11 | 12 | We'd love to accept your sample apps and patches! Before we can take them, we 13 | have to jump a couple of legal hurdles. 14 | 15 | Please fill out either the individual or corporate Contributor License Agreement 16 | (CLA). 17 | 18 | * If you are an individual writing original source code and you're sure you 19 | own the intellectual property, then you'll need to sign an [individual CLA](https://developers.google.com/open-source/cla/individual). 20 | * If you work for a company that wants to allow you to contribute your work, 21 | then you'll need to sign a [corporate CLA](https://developers.google.com/open-source/cla/corporate). 22 | 23 | Follow either of the two links above to access the appropriate CLA and 24 | instructions for how to sign and return it. Once we receive it, we'll be able to 25 | accept your pull requests. 26 | 27 | ## Contributing A Patch 28 | 29 | 1. Submit an issue describing your proposed change to the repo in question. 30 | 1. The repo owner will respond to your issue promptly. 31 | 1. If your proposed change is accepted, and you haven't already done so, sign a 32 | Contributor License Agreement (see details above). 33 | 1. Fork the desired repo, develop and test your code changes. 34 | 1. Ensure that your code adheres to the existing style in the code to which 35 | you are contributing. 36 | 1. Ensure that your code has an appropriate set of tests which all pass. 37 | 1. Title your pull request following [Conventional Commits](https://www.conventionalcommits.org/) styling. 38 | 1. Submit a pull request. 39 | 40 | ## Running the tests 41 | 42 | 1. [Prepare your environment for Node.js setup][setup]. 43 | 44 | 1. Install dependencies: 45 | 46 | npm install 47 | 48 | 1. Run the tests: 49 | 50 | # Run unit tests. 51 | # npm test 52 | 53 | # Run lint check. 54 | npm run lint 55 | 56 | 1. Lint any changes: 57 | 58 | npm run format 59 | npm run lint 60 | 61 | [setup]: https://cloud.google.com/nodejs/docs/setup -------------------------------------------------------------------------------- /src/main/scala/nl/pragmasoft/catracker/Database.scala: -------------------------------------------------------------------------------- 1 | package nl.pragmasoft.catracker 2 | 3 | import cats.effect.{Blocker, ContextShift, IO, Resource} 4 | import doobie.hikari.HikariTransactor 5 | import org.flywaydb.core.Flyway 6 | import cats.effect.IO 7 | import com.typesafe.scalalogging.LazyLogging 8 | import doobie.util.transactor.Transactor 9 | import fs2.Stream 10 | import doobie._ 11 | import doobie.implicits._ 12 | import doobie.implicits.javasql._ 13 | import doobie.implicits.javatime._ 14 | import nl.pragmasoft.catracker.Config.DatabaseConfig 15 | import nl.pragmasoft.catracker.Model.{PositionRepository, StoredPosition} 16 | 17 | import scala.concurrent.ExecutionContext 18 | 19 | object Database extends LazyLogging { 20 | def transactor(config: DatabaseConfig, executionContext: ExecutionContext, blocker: Blocker)(implicit contextShift: ContextShift[IO]): Resource[IO, HikariTransactor[IO]] = 21 | HikariTransactor.newHikariTransactor[IO]( 22 | config.driver, 23 | config.url, 24 | config.user, 25 | config.password, 26 | executionContext, 27 | blocker 28 | ) 29 | 30 | def initialize(transactor: HikariTransactor[IO]): IO[Unit] = 31 | transactor.configure { dataSource => 32 | IO { 33 | logger.info(s"Migrations for $dataSource") 34 | val flyWay = Flyway.configure().dataSource(dataSource).load() 35 | flyWay.migrate() 36 | } 37 | } 38 | } 39 | 40 | class PositionDatabase(transactor: Transactor[IO]) extends PositionRepository[IO] { 41 | 42 | def add(p: StoredPosition): IO[Unit] = 43 | sql"INSERT INTO positions (recorded, app, deviceType, deviceSerial, latitude, longitude, positionFix, bestGateway, bestSNR, battery, accuracy, temperature, counter) VALUES (${p.recorded}, ${p.app}, ${p.deviceType}, ${p.deviceSerial}, ${p.latitude}, ${p.longitude}, ${p.positionFix}, ${p.bestGateway}, ${p.bestSNR}, ${p.battery}, ${p.accuracy}, ${p.temperature}, ${p.counter})".update 44 | .withUniqueGeneratedKeys[Long]("id") 45 | .transact(transactor) 46 | .map(_ => ()) 47 | 48 | def findForDevice(deviceSerial: String): IO[List[StoredPosition]] = 49 | sql"SELECT id, recorded, app, deviceType, deviceSerial, latitude, longitude, positionFix, bestGateway, bestSNR, battery, accuracy, temperature, counter FROM positions WHERE deviceSerial=$deviceSerial order by recorded desc limit 100" 50 | .query[StoredPosition] 51 | .to[List] 52 | .transact(transactor) 53 | 54 | } 55 | -------------------------------------------------------------------------------- /deployment.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: apps/v1 2 | kind: Deployment 3 | metadata: 4 | name: catracker 5 | labels: 6 | uri: catracker.eu 7 | spec: 8 | selector: 9 | matchLabels: 10 | uri: catracker.eu 11 | template: 12 | metadata: 13 | labels: 14 | uri: catracker.eu 15 | spec: 16 | containers: 17 | - name: http 18 | imagePullPolicy: Always 19 | image: eblovich/catracker-http:${APP_VERSION} 20 | securityContext: 21 | allowPrivilegeEscalation: false 22 | runAsNonRoot: true 23 | runAsUser: 10002 24 | ports: 25 | - containerPort: 8080 26 | resources: 27 | limits: 28 | memory: 128Mi 29 | requests: 30 | memory: 64Mi 31 | volumeMounts: 32 | - mountPath: /var/cache/nginx 33 | name: cache-http 34 | - name: app 35 | imagePullPolicy: Always 36 | image: eblovich/catracker-service:${APP_VERSION} 37 | env: 38 | - name: DATABASE_PASSWORD 39 | valueFrom: 40 | secretKeyRef: 41 | name: catracker 42 | key: DATABASE_PASSWORD 43 | securityContext: 44 | allowPrivilegeEscalation: false 45 | runAsNonRoot: true 46 | runAsUser: 10002 47 | ports: 48 | - containerPort: 8081 49 | resources: 50 | limits: 51 | memory: 1G 52 | requests: 53 | memory: 512Mi 54 | volumes: 55 | - name: cache 56 | emptyDir: {} 57 | - name: cache-http 58 | emptyDir: {} 59 | --- 60 | apiVersion: v1 61 | kind: Service 62 | metadata: 63 | name: catracker-service 64 | spec: 65 | selector: 66 | uri: catracker.eu 67 | type: ClusterIP 68 | ports: 69 | - name: http 70 | protocol: TCP 71 | targetPort: 8080 72 | port: 8080 73 | --- 74 | apiVersion: networking.k8s.io/v1 75 | kind: Ingress 76 | metadata: 77 | name: catracker-ingress 78 | labels: 79 | uri: catracker.eu 80 | annotations: 81 | kubernetes.io/ingress.class: "nginx" 82 | cert-manager.io/cluster-issuer: "letsencrypt-prod" 83 | nginx.ingress.kubernetes.io/force-ssl-redirect: "True" 84 | spec: 85 | rules: 86 | - host: catracker.eu 87 | http: 88 | paths: 89 | - path: / 90 | pathType: Prefix 91 | backend: 92 | service: 93 | name: catracker-service 94 | port: 95 | number: 8080 96 | tls: 97 | - hosts: 98 | - catracker.eu 99 | secretName: catracker-tls 100 | --- 101 | kind: Secret 102 | apiVersion: v1 103 | metadata: 104 | name: catracker 105 | stringData: 106 | DATABASE_PASSWORD: ${DATABASE_PASSWORD} 107 | type: Opaque -------------------------------------------------------------------------------- /src/main/scala/nl/pragmasoft/catracker/Main.scala: -------------------------------------------------------------------------------- 1 | package nl.pragmasoft.catracker 2 | 3 | import akka.actor.typed.ActorSystem 4 | import cats.effect.{Blocker, ExitCode, IO, IOApp, Resource} 5 | import cats.implicits._ 6 | import com.typesafe.config.ConfigFactory 7 | import com.typesafe.scalalogging.LazyLogging 8 | import doobie.util.ExecutionContexts 9 | import org.http4s.HttpRoutes 10 | import org.http4s.dsl.io._ 11 | import org.http4s.implicits.http4sKleisliResponseSyntaxOptionT 12 | import org.http4s.server.blaze.BlazeServerBuilder 13 | import org.http4s.server.middleware.{CORS, Logger} 14 | import org.http4s.server.websocket._ 15 | import org.http4s.server.{Router, Server} 16 | import org.http4s.websocket.WebSocketFrame._ 17 | import org.slf4j.LoggerFactory 18 | 19 | import java.net.InetSocketAddress 20 | import scala.concurrent.ExecutionContext 21 | 22 | object Main extends IOApp with LazyLogging { 23 | 24 | override def run(args: List[String]): IO[ExitCode] = { 25 | val config = ConfigFactory.load() 26 | implicit val system: ActorSystem[TrackerProtocol.Command] = ActorSystem(Trackers(timer), "main", config) 27 | implicit val executionContext: ExecutionContext = system.executionContext 28 | 29 | val apiLoggingAction: Option[String => IO[Unit]] = { 30 | val apiLogger = LoggerFactory.getLogger("HTTP") 31 | Some(s => IO(apiLogger.info(s))) 32 | } 33 | 34 | val mainResource: Resource[IO, Server[IO]] = 35 | for { 36 | config <- Config.load() 37 | ec <- ExecutionContexts.fixedThreadPool[IO](config.database.threadPoolSize) 38 | blocker <- Blocker[IO] 39 | transactor <- Database.transactor(config.database, ec, blocker) 40 | _ <- Resource.liftF(Database.initialize(transactor)) 41 | repository = new PositionDatabase(transactor) 42 | handler = new http.Resource[IO]().routes(new ApiHandler[IO](repository, system)) 43 | apiService <- BlazeServerBuilder[IO](executionContext) 44 | .withNio2(true) 45 | .bindSocketAddress(InetSocketAddress.createUnresolved("0.0.0.0", 8081)) 46 | .withHttpApp(Logger.httpApp(logHeaders = true, logBody = true, logAction = apiLoggingAction)(Router("/api/catracker" -> (CORS(HttpRoutes.of[IO] { 47 | case GET -> Root / "health" => Ok() 48 | case GET -> Root / "ws" / device / connectionId => 49 | WebSocketBuilder[IO].build( 50 | send = Trackers.toClient(DeviceId(device), ConnectionId(connectionId)), 51 | receive = Trackers.fromClient(DeviceId(device), ConnectionId(connectionId)), 52 | onClose = Trackers.close(DeviceId(device), ConnectionId(connectionId)) 53 | ) 54 | 55 | } <+> handler))) orNotFound)) 56 | .resource 57 | } yield apiService 58 | mainResource.use(_ => IO.never).as(ExitCode.Success) 59 | } 60 | 61 | } 62 | -------------------------------------------------------------------------------- /src/main/scala/nl/pragmasoft/catracker/ApiHandler.scala: -------------------------------------------------------------------------------- 1 | package nl.pragmasoft.catracker 2 | 3 | import akka.actor.typed.ActorSystem 4 | import cats.effect.Async 5 | import cats.implicits._ 6 | import com.typesafe.scalalogging.LazyLogging 7 | import nl.pragmasoft.catracker.Model.{PositionRepository, StoredPosition} 8 | import nl.pragmasoft.catracker.TrackerProtocol.UpdatePosition 9 | import nl.pragmasoft.catracker.http.definitions.{DevicePath, KpnEventRecord, TtnEvent} 10 | import nl.pragmasoft.catracker.http.{Handler, IncomingEventKpnResponse, IncomingEventTtnResponse, PathForDeviceResponse} 11 | 12 | import java.time.{LocalDateTime, ZoneOffset} 13 | 14 | class ApiHandler[F[_]: Async](positions: PositionRepository[F], system: ActorSystem[TrackerProtocol.Command]) extends Handler[F] with LazyLogging { 15 | def pathForDevice(respond: PathForDeviceResponse.type)(device: String): F[PathForDeviceResponse] = 16 | for { 17 | lastPositions <- positions.findForDevice(device) 18 | allPathPositions = lastPositions.distinctBy(_.recorded).filter(p => p.accuracy <= 16 && p.positionFix && p.longitude != 0 && p.latitude != 0) 19 | pathPositions = allPathPositions.headOption map { head => 20 | List(head) ++ 21 | allPathPositions.tail.filter(head.recorded - _.recorded < Tracker.TrackingTailLength) 22 | } getOrElse List.empty 23 | _ = logger.info(s"Fetching path for $device") 24 | } yield { 25 | val lastSeen = pathPositions.headOption.map(p => LocalDateTime.now().atOffset(ZoneOffset.UTC).toInstant.toEpochMilli - p.recorded).getOrElse(0L) 26 | PathForDeviceResponse.Ok( 27 | DevicePath( 28 | description = pathPositions.headOption.map(p => s"${p.app} ${p.deviceType} ${p.deviceSerial}").getOrElse("?"), 29 | lastSeen = BigDecimal(lastSeen), 30 | positions = pathPositions 31 | .map(p => DevicePath.Positions(p.latitude.doubleValue, p.longitude.doubleValue, p.battery)) 32 | .toVector 33 | ) 34 | ) 35 | } 36 | 37 | def incomingEventTtn(respond: IncomingEventTtnResponse.type)(e: TtnEvent): F[IncomingEventTtnResponse] = { 38 | val gw = e.uplinkMessage.rxMetadata.maxBy(_.snr) 39 | val p = e.uplinkMessage.decodedPayload 40 | val position = StoredPosition( 41 | recorded = e.uplinkMessage.receivedAt.toInstant.toEpochMilli, 42 | app = e.endDeviceIds.applicationIds.applicationId, 43 | deviceType = e.endDeviceIds.deviceId, 44 | deviceSerial = e.endDeviceIds.devEui, 45 | latitude = p.latitude, 46 | longitude = p.longitude, 47 | positionFix = p.fix, 48 | bestGateway = gw.gatewayIds.gatewayId, 49 | bestSNR = gw.snr, 50 | accuracy = p.accuracy, 51 | battery = p.capacity.toInt, 52 | temperature = p.temperature, 53 | counter = e.uplinkMessage.fCnt 54 | ) 55 | system ! UpdatePosition(position) 56 | 57 | for { 58 | _ <- positions.add(position) 59 | } yield IncomingEventTtnResponse.Created 60 | } 61 | 62 | def incomingEventKpn(respond: IncomingEventKpnResponse.type)(body: Vector[KpnEventRecord]): F[IncomingEventKpnResponse] = { 63 | 64 | val position = KpnEvent.decode(body).get 65 | system ! UpdatePosition(position) 66 | for { 67 | _ <- positions.add(position) 68 | } yield IncomingEventKpnResponse.Created 69 | 70 | } 71 | 72 | } 73 | -------------------------------------------------------------------------------- /src/test/scala/nl/pragmasoft/catracker/ParserSpec.scala: -------------------------------------------------------------------------------- 1 | package nl.pragmasoft.catracker 2 | 3 | import cats.effect.IO 4 | import io.circe 5 | import io.circe.parser._ 6 | import nl.pragmasoft.catracker.Model.{PositionRepository, StoredPosition} 7 | import nl.pragmasoft.catracker.http.definitions.{KpnEventRecord, TtnEvent} 8 | import org.scalatest.matchers.should.Matchers 9 | import org.scalamock.scalatest.MockFactory 10 | import org.scalatest.wordspec.AnyWordSpec 11 | 12 | import scala.io.Source 13 | import cats.effect.IO 14 | import fs2.Stream 15 | import io.circe.Json 16 | import org.http4s.circe._ 17 | import org.http4s.dsl.io._ 18 | import org.http4s.implicits._ 19 | import org.http4s.{Request, Response, Status, Uri, _} 20 | import org.scalamock.scalatest.MockFactory 21 | import org.scalatest.matchers.should.Matchers 22 | import org.scalatest.wordspec.AnyWordSpec 23 | 24 | import java.time.ZoneOffset 25 | 26 | class ParserSpec extends AnyWordSpec with MockFactory with Matchers { 27 | 28 | import java.time.LocalDateTime 29 | import java.time.format.DateTimeFormatter._ 30 | 31 | val dts = "2018-12-13T19:19:08.266120+00:00" 32 | LocalDateTime.parse(dts, ISO_DATE_TIME) 33 | "sample JSON" should { 34 | "be parsed from tabs tracker" in { 35 | 36 | TtnEvent.decodeTtnEvent.decodeJson( 37 | parse( 38 | Source.fromResource("http-ttn.json").getLines().mkString("\n") 39 | ).left.map(f => fail(s"can't parse: $f")).merge 40 | ) shouldBe Right( 41 | TtnEvent( 42 | "pragma_cats_tabs", 43 | "tabs_test1", 44 | "58A0CB0000204688", 45 | 85, 46 | TtnEvent.PayloadFields(16, 93.33333333333333, false, 52.331984, 4.944248, Some(136), 18, 3.9), 47 | TtnEvent.Metadata( 48 | LocalDateTime.parse("2020-12-25T12:07:49.102008218Z", ISO_DATE_TIME).atOffset(ZoneOffset.UTC), 49 | 868.5, 50 | "LORA", 51 | "SF12BW125", 52 | "4/5", 53 | Vector(TtnEvent.Metadata.Gateways("eui-58a0cbfffe802a34", LocalDateTime.parse("2020-12-25T12:07:49.168767929Z", ISO_DATE_TIME).atOffset(ZoneOffset.UTC), 0, -68, 6.5)) 54 | ), 55 | Some("https://integrations.thethingsnetwork.org/ttn-eu/api/v2/down/pragma_cats_tabs/requestbin?key=ttn-account-v2.yG7tTCBkMQ8Ktg35n6rBsiUEGPIWLdf36mW_v_Mfwp8") 56 | ) 57 | ) 58 | } 59 | 60 | "be parsed from dragino tracker" in { 61 | 62 | TtnEvent.decodeTtnEvent.decodeJson( 63 | parse( 64 | Source.fromResource("http-ttn-dragino.json").getLines().mkString("\n") 65 | ).left.map(f => fail(s"can't parse: $f")).merge 66 | ) shouldBe 67 | Right( 68 | TtnEvent( 69 | "pragma_cats_dragino", 70 | "dragino_test1", 71 | "A840416B61826E5F", 72 | 93, 73 | TtnEvent.PayloadFields(0, 0, false, 52.331956, 4.944941, Some(2), 0, 3.685), 74 | TtnEvent.Metadata( 75 | LocalDateTime.parse("2020-12-28T11:36:17.269018381Z", ISO_DATE_TIME).atOffset(ZoneOffset.UTC), 76 | 868.1, 77 | "LORA", 78 | "SF7BW125", 79 | "4/5", 80 | Vector( 81 | TtnEvent.Metadata.Gateways("eui-58a0cbfffe802a34", LocalDateTime.parse("2020-12-28T11:36:17.480709075Z", ISO_DATE_TIME).atOffset(ZoneOffset.UTC), 0, -87, 9.75) 82 | ) 83 | ), 84 | Some("https://integrations.thethingsnetwork.org/ttn-eu/api/v2/down/pragma_cats_dragino/catracker?key=ttn-account-v2.q3vCLU1Une4Z7lxiSy3P1ZG8cBfaxQB66AbnL02aHNg") 85 | ) 86 | ) 87 | } 88 | 89 | "be parsed from Browan via KPN" in { 90 | KpnEvent.decode( 91 | parse( 92 | Source.fromResource("kpn.json").getLines().mkString("\n") 93 | ).left 94 | .map(f => fail(s"can't parse: $f")) 95 | .merge 96 | .asArray 97 | .get 98 | .map(json => KpnEventRecord.decodeKpnEventRecord.decodeJson(json).left.map(f => fail(s"can't parse: $f")).merge) 99 | ) shouldBe 100 | Some(StoredPosition(1618061599, 1618061599, "kpn", "kpn", "E8E1E10001060A56", 0.0, 0.0, false, "", 0, 93, 4, 20, 0)) 101 | KpnEvent.decode( 102 | parse( 103 | Source.fromResource("kpn-2.json").getLines().mkString("\n") 104 | ).left 105 | .map(f => fail(s"can't parse: $f")) 106 | .merge 107 | .asArray 108 | .get 109 | .map(json => KpnEventRecord.decodeKpnEventRecord.decodeJson(json).left.map(f => fail(s"can't parse: $f")).merge) 110 | ) shouldBe 111 | Some(StoredPosition(1618049415, 1618049415, "kpn", "kpn", "E8E1E10001060A56", 52.331967, 4.944089, false, "", 0, 93, 16, 19, 0)) 112 | } 113 | } 114 | 115 | } 116 | -------------------------------------------------------------------------------- /frontend/src/index.ts: -------------------------------------------------------------------------------- 1 | import {WebsocketBuilder, ExponentialBackoff} from 'websocket-ts'; 2 | import {v4 as uuidv4} from 'uuid'; 3 | 4 | interface DevicePath { 5 | description: string; 6 | lastSeen: number; 7 | positions: Array; 8 | } 9 | 10 | interface Position { 11 | latitude: number; 12 | longitude: number; 13 | battery: number; 14 | } 15 | 16 | function api(url: string): Promise { 17 | return fetch(url) 18 | .then(response => { 19 | if (!response.ok) { 20 | throw new Error(response.statusText) 21 | } 22 | return response.json(); 23 | }) 24 | } 25 | 26 | export function forceCast(input: any): T { 27 | return input; 28 | } 29 | 30 | var currentData: DevicePath 31 | var currentMarker: google.maps.Marker 32 | var currentPath: google.maps.Polyline 33 | var currentTimer: number = 0 34 | 35 | function msToTime(duration: number) { 36 | if (duration < 60000) return "~"; 37 | const millisecondsValue: number = Math.floor((duration % 1000) / 100), 38 | secondsValue: number = Math.floor((duration / 1000) % 60), 39 | minutesValue: number = Math.floor((duration / (1000 * 60)) % 60), 40 | hoursValue: number = Math.floor((duration / (1000 * 60 * 60)) % 24); 41 | 42 | const hours = (hoursValue < 10) ? "0" + hoursValue : hoursValue; 43 | const minutes = (minutesValue < 10) ? "0" + minutesValue : minutesValue; 44 | const seconds = (secondsValue < 10) ? "0" + secondsValue : secondsValue; 45 | 46 | return hours > 0 ? hours + ":" + minutes + ":" + seconds : minutes + ":" + seconds; 47 | } 48 | 49 | 50 | function updateLabel(start: number, lastSeen: number, battery: string): void { 51 | const elapsed: number = lastSeen + (Date.now() - start); 52 | const batteryLabel = document.getElementById("battery") as HTMLElement; 53 | currentMarker.setLabel({ text: msToTime(elapsed), fontSize: "40px" }); 54 | batteryLabel.textContent = battery; 55 | } 56 | 57 | function redraw(map: google.maps.Map, data: DevicePath, lastSeen: number): void { 58 | const last = data.positions[0]; 59 | 60 | map.setCenter({ lat: last.latitude, lng: last.longitude}); 61 | const battery = last.battery > 0 ? last.battery + "%" : "~"; 62 | 63 | currentPath = new google.maps.Polyline({ 64 | path: data.positions.map( (p: Position) => new google.maps.LatLng({ lat: p.latitude, lng: p.longitude }) ), 65 | geodesic: true, 66 | strokeColor: "#FF0000", 67 | strokeOpacity: 1.0, 68 | strokeWeight: 5, 69 | map: map 70 | }); 71 | 72 | currentMarker = new google.maps.Marker( { 73 | position: { 74 | lat: last.latitude, 75 | lng: last.longitude, 76 | }, 77 | opacity: 1, 78 | icon: {url: "/marker.png", labelOrigin: new google.maps.Point(40,90) }, 79 | map: map 80 | } 81 | ); 82 | clearInterval(currentTimer); 83 | const start: number = Date.now(); 84 | updateLabel(start, lastSeen, battery); 85 | currentTimer = setInterval( 86 | () => { 87 | updateLabel(start, lastSeen, battery) 88 | }, 89 | 1000); 90 | } 91 | 92 | function initMap(): void { 93 | 94 | const device = window.location.pathname.split("/")[1]; 95 | const location = window.location; 96 | 97 | var wsUrl: string; 98 | if (location.protocol === "https:") { 99 | wsUrl = "wss:"; 100 | } else { 101 | wsUrl = "ws:"; 102 | } 103 | const host = location.host; 104 | // const host = "localhost:8081"; 105 | 106 | wsUrl += "//" + host + "/api/catracker/ws/" + device + "/" + uuidv4(); 107 | const dataUrl = location.protocol + "//" + host + '/api/catracker/paths/' + device; 108 | api(dataUrl) 109 | .then( 110 | data => { 111 | console.log(document.getElementById("map") ) 112 | const map = new google.maps.Map( 113 | document.getElementById("map") as HTMLElement, 114 | { zoom: 18 } 115 | ); 116 | currentData = data; 117 | redraw(map, currentData, data.lastSeen); 118 | 119 | const ws = new WebsocketBuilder(wsUrl) 120 | .onOpen((i, ev) => { console.log("opened"); }) 121 | .onClose((i, ev) => { console.log("closed") }) 122 | .onError((i, ev) => { console.log("error") }) 123 | .onMessage((i, ev) => { 124 | if (ev.data != "") { // initial frame 125 | const json = JSON.parse(ev.data) 126 | console.log(json); 127 | if (json.hasOwnProperty("Path")) { 128 | console.log(json.Path.positions); 129 | currentData.positions = json.Path.positions; 130 | currentMarker.setMap(null); 131 | currentPath.setMap(null); 132 | redraw(map, currentData, 0); 133 | } 134 | } 135 | }) 136 | .onRetry((i, ev) => { console.log("retry") }) 137 | .withBackoff(new ExponentialBackoff(100, 7)) 138 | .build(); 139 | 140 | } 141 | ); 142 | } 143 | export { initMap }; 144 | 145 | import "./style.css"; 146 | -------------------------------------------------------------------------------- /src/main/scala/nl/pragmasoft/catracker/Tracker.scala: -------------------------------------------------------------------------------- 1 | package nl.pragmasoft.catracker 2 | 3 | import akka.actor.typed.scaladsl.Behaviors 4 | import akka.actor.typed.{ActorRef, Behavior} 5 | import akka.persistence.typed.scaladsl.{Effect, EventSourcedBehavior, RetentionCriteria} 6 | import akka.persistence.typed.{PersistenceId, RecoveryCompleted} 7 | import cats.effect.{Concurrent, IO, Timer} 8 | import cats.implicits._ 9 | import com.mysql.cj.x.protobuf.Mysqlx.ServerMessages 10 | import com.typesafe.scalalogging.LazyLogging 11 | import io.circe._ 12 | import io.circe.generic.semiauto._ 13 | import io.circe.parser._ 14 | import io.circe.syntax._ 15 | import fs2._ 16 | import fs2.concurrent.Topic 17 | import nl.pragmasoft.catracker.Model.StoredPosition 18 | import nl.pragmasoft.catracker.http.definitions.DevicePath.Positions 19 | import org.http4s.websocket.WebSocketFrame 20 | import org.http4s.websocket.WebSocketFrame.{Close, Ping, Text} 21 | 22 | import scala.collection.concurrent.TrieMap 23 | import scala.concurrent.duration.DurationInt 24 | 25 | object TrackerProtocol { 26 | // (external) API commands 27 | sealed trait Command 28 | final case class UpdatePosition(storedPosition: StoredPosition) extends Command 29 | 30 | final case class ServerMessage(deviceId: DeviceId, payload: ServerMessagePayload) extends Command 31 | 32 | sealed trait ServerMessagePayload 33 | final case class Path(positions: List[StoredPosition]) extends ServerMessagePayload 34 | final case object FixLost extends ServerMessagePayload 35 | final case object LiveTrackingStarted extends ServerMessagePayload 36 | final case class BatteryWarning(left: Int) extends ServerMessagePayload 37 | 38 | private val typeFieldName: String = "t" 39 | sealed trait ClientMessage 40 | case class AdjustFrequency(updatePerSeconds: Int) extends ClientMessage 41 | 42 | // (internal) commands changing state 43 | sealed trait StateCommand 44 | final case class Update(position: StoredPosition) extends StateCommand 45 | final case class IncomingClientMessage(connectionId: ConnectionId, clientMessage: ClientMessage) extends StateCommand 46 | 47 | implicit val positionEncoder: Encoder[StoredPosition] = deriveEncoder[StoredPosition] 48 | implicit val pathEncoder: Encoder[Path] = deriveEncoder[Path] 49 | implicit val fixLostEncoder: Encoder[FixLost.type] = deriveEncoder[FixLost.type] 50 | implicit val liveTrackingStartedEncoder: Encoder[LiveTrackingStarted.type] = deriveEncoder[LiveTrackingStarted.type] 51 | implicit val batteryWarningEncoder: Encoder[BatteryWarning] = deriveEncoder[BatteryWarning] 52 | implicit val serverMessagePayloadDecoder: Encoder[ServerMessagePayload] = deriveEncoder[ServerMessagePayload] 53 | 54 | implicit val adjustFrequencyDecoder: Decoder[AdjustFrequency] = deriveDecoder[AdjustFrequency] 55 | implicit val clientMessageDecoder: Decoder[ClientMessage] = deriveDecoder[ClientMessage] 56 | 57 | } 58 | 59 | object Trackers extends LazyLogging { 60 | 61 | import TrackerProtocol._ 62 | 63 | def toClient(deviceId: DeviceId, connectionId: ConnectionId)(implicit concurrent: Concurrent[IO]): Stream[IO, WebSocketFrame] = { 64 | logger.info(s"$deviceId $connectionId: connected") 65 | activeConnections 66 | .getOrElseUpdate(deviceId, TrieMap.empty) 67 | .getOrElseUpdate(connectionId, Topic[IO, WebSocketFrame](Text("")).unsafeRunSync()) 68 | .subscribe(10) 69 | } 70 | 71 | def fromClient(deviceId: DeviceId, connectionId: ConnectionId)(wsfStream: Stream[IO, WebSocketFrame]): Stream[IO, Unit] = 72 | wsfStream.collect { 73 | case Text(text, _) => 74 | (for { 75 | json <- parse(text) 76 | message <- clientMessageDecoder.decodeJson(json) 77 | } yield message) 78 | .foreach(m => activeDevices.get(deviceId).map(_ ! IncomingClientMessage(connectionId, m))) 79 | 80 | case Close(_) => close(deviceId, connectionId) 81 | } 82 | 83 | def close(deviceId: DeviceId, connectionId: ConnectionId): IO[Unit] = 84 | IO { 85 | activeConnections.get(deviceId).foreach(_.remove(connectionId)) 86 | logger.info(s"$deviceId $connectionId: disconnected") 87 | } 88 | 89 | private val activeConnections: TrieMap[DeviceId, TrieMap[ConnectionId, Topic[IO, WebSocketFrame]]] = TrieMap.empty 90 | private val activeDevices: TrieMap[DeviceId, ActorRef[StateCommand]] = TrieMap.empty 91 | 92 | def apply(timer: Timer[IO]): Behavior[Command] = 93 | Behaviors.setup { context => 94 | def ping: IO[Unit] = IO(activeConnections.foreach(_._2.foreach(_._2.publish1(Ping())))) 95 | def repeat: IO[Unit] = ping >> timer.sleep(duration = 10 seconds) >> repeat 96 | repeat.unsafeRunAsyncAndForget() 97 | 98 | def tracker(device: DeviceId): ActorRef[StateCommand] = 99 | activeDevices.getOrElseUpdate(device, context.spawn(Tracker(device, context.self), device.value)) 100 | 101 | Behaviors.receiveMessage { 102 | case UpdatePosition(storedPosition) => 103 | tracker(DeviceId(storedPosition.deviceSerial)) ! Update(storedPosition) 104 | Behaviors.same 105 | case ServerMessage(deviceId, payload) => 106 | activeConnections 107 | .get(deviceId) 108 | .foreach(d => 109 | d.foreach { 110 | case (_, topic) => 111 | topic.publish1(Text(payload.asJson.toString)).unsafeRunAsyncAndForget() 112 | } 113 | ) 114 | Behaviors.same 115 | } 116 | } 117 | } 118 | 119 | object Tracker { 120 | 121 | import TrackerProtocol._ 122 | val TrackingTailLength: Long = (60 minutes).toMillis 123 | 124 | sealed trait Event 125 | final case class PositionUpdated(position: StoredPosition) extends Event 126 | 127 | case class State(deviceId: DeviceId, positions: List[StoredPosition]) { 128 | def messagesOnIncomingPosition(incoming: StoredPosition): List[ServerMessage] = 129 | positions match { 130 | case Nil => List(ServerMessage(deviceId, LiveTrackingStarted)) 131 | case last :: _ 132 | if incoming.positionFix && 133 | (!last.positionFix || (incoming.recorded - last.recorded) > TrackingTailLength) => 134 | List(ServerMessage(deviceId, LiveTrackingStarted)) 135 | case p :: _ if p.positionFix && !incoming.positionFix => 136 | List(ServerMessage(deviceId, FixLost)) 137 | 138 | case _ => List.empty 139 | } 140 | } 141 | private def appendPosition(positions: List[StoredPosition], newPosition: StoredPosition): List[StoredPosition] = 142 | positions.headOption 143 | .map(h => 144 | if (newPosition.recorded > h.recorded + TrackingTailLength) List(newPosition) 145 | else newPosition :: positions 146 | ) 147 | .getOrElse(List(newPosition)) 148 | 149 | def apply(device: DeviceId, parent: ActorRef[Command]): Behavior[StateCommand] = 150 | EventSourcedBehavior[StateCommand, Event, State]( 151 | persistenceId = PersistenceId.ofUniqueId(device.value), 152 | emptyState = State(device, List.empty), 153 | commandHandler = { (state, command) => 154 | command match { 155 | case Update(position) => 156 | state.messagesOnIncomingPosition(position).foreach(parent ! _) 157 | Effect.persist(PositionUpdated(position)) 158 | case IncomingClientMessage(connectionId, clientMessage) => 159 | // todo process message 160 | Effect.none 161 | } 162 | }, 163 | eventHandler = { 164 | case (state, event: PositionUpdated) => 165 | val newState = state.copy(positions = appendPosition(state.positions, event.position)) 166 | parent ! ServerMessage(device, Path(newState.positions)) 167 | newState 168 | } 169 | ) 170 | .withRetention(RetentionCriteria.snapshotEvery(numberOfEvents = 100, keepNSnapshots = 2).withDeleteEventsOnSnapshot) 171 | .receiveSignal { 172 | case (state, RecoveryCompleted) if state.positions.nonEmpty => parent ! ServerMessage(device, Path(state.positions)) 173 | } 174 | 175 | } 176 | -------------------------------------------------------------------------------- /api.yaml: -------------------------------------------------------------------------------- 1 | openapi: 3.0.1 2 | info: 3 | title: Catracker 4 | version: 0.1 5 | description: Catracker 6 | paths: 7 | /ttnhttp: 8 | post: 9 | operationId: incomingEventTtn 10 | requestBody: 11 | required: true 12 | content: 13 | application/json: 14 | schema: 15 | $ref: '#/definitions/TTNEvent' 16 | responses: 17 | '201': 18 | description: Created 19 | /kpn: 20 | post: 21 | operationId: incomingEventKpn 22 | requestBody: 23 | required: true 24 | content: 25 | application/json: 26 | schema: 27 | type: array 28 | items: 29 | $ref: '#/definitions/KPNEventRecord' 30 | responses: 31 | '201': 32 | description: Created 33 | /paths/{device}: 34 | get: 35 | operationId: pathForDevice 36 | parameters: 37 | - in: path 38 | name: device 39 | schema: 40 | type: string 41 | required: true 42 | responses: 43 | '200': 44 | description: OK response 45 | content: 46 | application/json: 47 | schema: 48 | $ref: '#/components/schemas/DevicePath' 49 | 50 | servers: 51 | - url: http://host 52 | components: 53 | schemas: 54 | DevicePath: 55 | type: object 56 | required: 57 | - description 58 | - positions 59 | - lastSeen 60 | properties: 61 | description: 62 | type: string 63 | lastSeen: 64 | type: number 65 | positions: 66 | type: array 67 | items: 68 | type: object 69 | required: 70 | - latitude 71 | - longitude 72 | - battery 73 | properties: 74 | latitude: 75 | type: number 76 | format: double 77 | longitude: 78 | type: number 79 | format: double 80 | battery: 81 | type: number 82 | KPNEventRecord: 83 | type: object 84 | properties: 85 | bn: 86 | type: string 87 | bt: 88 | type: number 89 | v: 90 | type: number 91 | vs: 92 | type: string 93 | n: 94 | type: string 95 | TTNEvent: 96 | type: object 97 | required: 98 | - end_device_ids 99 | - uplink_message 100 | - received_at 101 | properties: 102 | end_device_ids: 103 | type: object 104 | required: 105 | - device_id 106 | - application_ids 107 | - dev_eui 108 | - join_eui 109 | - dev_addr 110 | properties: 111 | device_id: 112 | type: string 113 | application_ids: 114 | type: object 115 | required: 116 | - application_id 117 | properties: 118 | application_id: 119 | type: string 120 | dev_eui: 121 | type: string 122 | join_eui: 123 | type: string 124 | dev_addr: 125 | type: string 126 | correlation_ids: 127 | type: array 128 | items: 129 | type: string 130 | received_at: 131 | type: string 132 | uplink_message: 133 | type: object 134 | required: 135 | - decoded_payload 136 | - f_port 137 | - f_cnt 138 | - rx_metadata 139 | - settings 140 | - received_at 141 | - consumed_airtime 142 | properties: 143 | session_key_id: 144 | type: string 145 | f_port: 146 | type: integer 147 | format: int32 148 | f_cnt: 149 | type: integer 150 | format: int32 151 | frm_payload: 152 | type: string 153 | decoded_payload: 154 | type: object 155 | required: 156 | - accuracy 157 | - capacity 158 | - fix 159 | - latitude 160 | - longitude 161 | - temperature 162 | - voltage 163 | properties: 164 | accuracy: 165 | type: integer 166 | format: int32 167 | capacity: 168 | type: number 169 | fix: 170 | type: boolean 171 | latitude: 172 | type: number 173 | format: double 174 | longitude: 175 | type: number 176 | format: double 177 | temperature: 178 | type: integer 179 | format: int32 180 | voltage: 181 | type: number 182 | rx_metadata: 183 | type: array 184 | items: 185 | type: object 186 | required: 187 | - gateway_ids 188 | - time 189 | - timestamp 190 | - rssi 191 | - channel_rssi 192 | - snr 193 | - received_at 194 | - uplink_token 195 | properties: 196 | gateway_ids: 197 | type: object 198 | required: 199 | - gateway_id 200 | - eui 201 | properties: 202 | gateway_id: 203 | type: string 204 | eui: 205 | type: string 206 | time: 207 | type: string 208 | timestamp: 209 | type: integer 210 | format: int64 211 | rssi: 212 | type: integer 213 | format: int32 214 | channel_rssi: 215 | type: integer 216 | format: int32 217 | snr: 218 | type: number 219 | uplink_token: 220 | type: string 221 | received_at: 222 | type: string 223 | format: date-time 224 | settings: 225 | type: object 226 | required: 227 | - data_rate 228 | - frequency 229 | - timestamp 230 | properties: 231 | data_rate: 232 | type: object 233 | required: 234 | - loar 235 | properties: 236 | lora: 237 | type: object 238 | required: 239 | - bandwidth 240 | - spreading_factor 241 | - coding_rate 242 | properties: 243 | bandwidth: 244 | type: integer 245 | format: int32 246 | spreading_factor: 247 | type: integer 248 | format: int32 249 | coding_rate: 250 | type: string 251 | frequency: 252 | type: string 253 | timestamp: 254 | type: integer 255 | format: int64 256 | time: 257 | type: string 258 | received_at: 259 | type: string 260 | format: date-time 261 | consumed_airtime: 262 | type: string 263 | network_ids: 264 | type: object 265 | properties: 266 | net_id: 267 | type: string 268 | tenant_id: 269 | type: string 270 | cluster_id: 271 | type: string 272 | cluster_address: 273 | type: string 274 | 275 | -------------------------------------------------------------------------------- /frontend/LICENSE: -------------------------------------------------------------------------------- 1 | 2 | Apache License 3 | Version 2.0, January 2004 4 | http://www.apache.org/licenses/ 5 | 6 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 7 | 8 | 1. Definitions. 9 | 10 | "License" shall mean the terms and conditions for use, reproduction, 11 | and distribution as defined by Sections 1 through 9 of this document. 12 | 13 | "Licensor" shall mean the copyright owner or entity authorized by 14 | the copyright owner that is granting the License. 15 | 16 | "Legal Entity" shall mean the union of the acting entity and all 17 | other entities that control, are controlled by, or are under common 18 | control with that entity. For the purposes of this definition, 19 | "control" means (i) the power, direct or indirect, to cause the 20 | direction or management of such entity, whether by contract or 21 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 22 | outstanding shares, or (iii) beneficial ownership of such entity. 23 | 24 | "You" (or "Your") shall mean an individual or Legal Entity 25 | exercising permissions granted by this License. 26 | 27 | "Source" form shall mean the preferred form for making modifications, 28 | including but not limited to software source code, documentation 29 | source, and configuration files. 30 | 31 | "Object" form shall mean any form resulting from mechanical 32 | transformation or translation of a Source form, including but 33 | not limited to compiled object code, generated documentation, 34 | and conversions to other media types. 35 | 36 | "Work" shall mean the work of authorship, whether in Source or 37 | Object form, made available under the License, as indicated by a 38 | copyright notice that is included in or attached to the work 39 | (an example is provided in the Appendix below). 40 | 41 | "Derivative Works" shall mean any work, whether in Source or Object 42 | form, that is based on (or derived from) the Work and for which the 43 | editorial revisions, annotations, elaborations, or other modifications 44 | represent, as a whole, an original work of authorship. For the purposes 45 | of this License, Derivative Works shall not include works that remain 46 | separable from, or merely link (or bind by name) to the interfaces of, 47 | the Work and Derivative Works thereof. 48 | 49 | "Contribution" shall mean any work of authorship, including 50 | the original version of the Work and any modifications or additions 51 | to that Work or Derivative Works thereof, that is intentionally 52 | submitted to Licensor for inclusion in the Work by the copyright owner 53 | or by an individual or Legal Entity authorized to submit on behalf of 54 | the copyright owner. For the purposes of this definition, "submitted" 55 | means any form of electronic, verbal, or written communication sent 56 | to the Licensor or its representatives, including but not limited to 57 | communication on electronic mailing lists, source code control systems, 58 | and issue tracking systems that are managed by, or on behalf of, the 59 | Licensor for the purpose of discussing and improving the Work, but 60 | excluding communication that is conspicuously marked or otherwise 61 | designated in writing by the copyright owner as "Not a Contribution." 62 | 63 | "Contributor" shall mean Licensor and any individual or Legal Entity 64 | on behalf of whom a Contribution has been received by Licensor and 65 | subsequently incorporated within the Work. 66 | 67 | 2. Grant of Copyright License. Subject to the terms and conditions of 68 | this License, each Contributor hereby grants to You a perpetual, 69 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 70 | copyright license to reproduce, prepare Derivative Works of, 71 | publicly display, publicly perform, sublicense, and distribute the 72 | Work and such Derivative Works in Source or Object form. 73 | 74 | 3. Grant of Patent License. Subject to the terms and conditions of 75 | this License, each Contributor hereby grants to You a perpetual, 76 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 77 | (except as stated in this section) patent license to make, have made, 78 | use, offer to sell, sell, import, and otherwise transfer the Work, 79 | where such license applies only to those patent claims licensable 80 | by such Contributor that are necessarily infringed by their 81 | Contribution(s) alone or by combination of their Contribution(s) 82 | with the Work to which such Contribution(s) was submitted. If You 83 | institute patent litigation against any entity (including a 84 | cross-claim or counterclaim in a lawsuit) alleging that the Work 85 | or a Contribution incorporated within the Work constitutes direct 86 | or contributory patent infringement, then any patent licenses 87 | granted to You under this License for that Work shall terminate 88 | as of the date such litigation is filed. 89 | 90 | 4. Redistribution. You may reproduce and distribute copies of the 91 | Work or Derivative Works thereof in any medium, with or without 92 | modifications, and in Source or Object form, provided that You 93 | meet the following conditions: 94 | 95 | (a) You must give any other recipients of the Work or 96 | Derivative Works a copy of this License; and 97 | 98 | (b) You must cause any modified files to carry prominent notices 99 | stating that You changed the files; and 100 | 101 | (c) You must retain, in the Source form of any Derivative Works 102 | that You distribute, all copyright, patent, trademark, and 103 | attribution notices from the Source form of the Work, 104 | excluding those notices that do not pertain to any part of 105 | the Derivative Works; and 106 | 107 | (d) If the Work includes a "NOTICE" text file as part of its 108 | distribution, then any Derivative Works that You distribute must 109 | include a readable copy of the attribution notices contained 110 | within such NOTICE file, excluding those notices that do not 111 | pertain to any part of the Derivative Works, in at least one 112 | of the following places: within a NOTICE text file distributed 113 | as part of the Derivative Works; within the Source form or 114 | documentation, if provided along with the Derivative Works; or, 115 | within a display generated by the Derivative Works, if and 116 | wherever such third-party notices normally appear. The contents 117 | of the NOTICE file are for informational purposes only and 118 | do not modify the License. You may add Your own attribution 119 | notices within Derivative Works that You distribute, alongside 120 | or as an addendum to the NOTICE text from the Work, provided 121 | that such additional attribution notices cannot be construed 122 | as modifying the License. 123 | 124 | You may add Your own copyright statement to Your modifications and 125 | may provide additional or different license terms and conditions 126 | for use, reproduction, or distribution of Your modifications, or 127 | for any such Derivative Works as a whole, provided Your use, 128 | reproduction, and distribution of the Work otherwise complies with 129 | the conditions stated in this License. 130 | 131 | 5. Submission of Contributions. Unless You explicitly state otherwise, 132 | any Contribution intentionally submitted for inclusion in the Work 133 | by You to the Licensor shall be under the terms and conditions of 134 | this License, without any additional terms or conditions. 135 | Notwithstanding the above, nothing herein shall supersede or modify 136 | the terms of any separate license agreement you may have executed 137 | with Licensor regarding such Contributions. 138 | 139 | 6. Trademarks. This License does not grant permission to use the trade 140 | names, trademarks, service marks, or product names of the Licensor, 141 | except as required for reasonable and customary use in describing the 142 | origin of the Work and reproducing the content of the NOTICE file. 143 | 144 | 7. Disclaimer of Warranty. Unless required by applicable law or 145 | agreed to in writing, Licensor provides the Work (and each 146 | Contributor provides its Contributions) on an "AS IS" BASIS, 147 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 148 | implied, including, without limitation, any warranties or conditions 149 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 150 | PARTICULAR PURPOSE. You are solely responsible for determining the 151 | appropriateness of using or redistributing the Work and assume any 152 | risks associated with Your exercise of permissions under this License. 153 | 154 | 8. Limitation of Liability. In no event and under no legal theory, 155 | whether in tort (including negligence), contract, or otherwise, 156 | unless required by applicable law (such as deliberate and grossly 157 | negligent acts) or agreed to in writing, shall any Contributor be 158 | liable to You for damages, including any direct, indirect, special, 159 | incidental, or consequential damages of any character arising as a 160 | result of this License or out of the use or inability to use the 161 | Work (including but not limited to damages for loss of goodwill, 162 | work stoppage, computer failure or malfunction, or any and all 163 | other commercial damages or losses), even if such Contributor 164 | has been advised of the possibility of such damages. 165 | 166 | 9. Accepting Warranty or Additional Liability. While redistributing 167 | the Work or Derivative Works thereof, You may choose to offer, 168 | and charge a fee for, acceptance of support, warranty, indemnity, 169 | or other liability obligations and/or rights consistent with this 170 | License. However, in accepting such obligations, You may act only 171 | on Your own behalf and on Your sole responsibility, not on behalf 172 | of any other Contributor, and only if You agree to indemnify, 173 | defend, and hold each Contributor harmless for any liability 174 | incurred by, or claims asserted against, such Contributor by reason 175 | of your accepting any such warranty or additional liability. 176 | 177 | END OF TERMS AND CONDITIONS 178 | 179 | APPENDIX: How to apply the Apache License to your work. 180 | 181 | To apply the Apache License to your work, attach the following 182 | boilerplate notice, with the fields enclosed by brackets "[]" 183 | replaced with your own identifying information. (Don't include 184 | the brackets!) The text should be enclosed in the appropriate 185 | comment syntax for the file format. We also recommend that a 186 | file or class name and description of purpose be included on the 187 | same "printed page" as the copyright notice for easier 188 | identification within third-party archives. 189 | 190 | Copyright [yyyy] [name of copyright owner] 191 | 192 | Licensed under the Apache License, Version 2.0 (the "License"); 193 | you may not use this file except in compliance with the License. 194 | You may obtain a copy of the License at 195 | 196 | http://www.apache.org/licenses/LICENSE-2.0 197 | 198 | Unless required by applicable law or agreed to in writing, software 199 | distributed under the License is distributed on an "AS IS" BASIS, 200 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 201 | See the License for the specific language governing permissions and 202 | limitations under the License. 203 | -------------------------------------------------------------------------------- /src/test/resources/sample.json: -------------------------------------------------------------------------------- 1 | {"app_id":"pragma_cats_tabs","dev_id":"tabs_test1","hardware_serial":"58A0CB0000204688","port":136,"counter":85,"payload_raw":"CO4y0IUeA3hxS0A=","payload_fields":{"accuracy":16,"bytes":"CO4y0IUeA3hxS0A=","capacity":93.33333333333333,"gnss_fix":false,"latitude":52.331984,"longitude":4.944248,"port":136,"temperature":18,"voltage":3.9},"metadata":{"time":"2020-12-25T12:07:49.102008218Z","frequency":868.5,"modulation":"LORA","data_rate":"SF12BW125","coding_rate":"4/5","gateways":[{"gtw_id":"eui-58a0cbfffe802a34","timestamp":48747028,"time":"2020-12-25T12:07:49.168767929Z","channel":0,"rssi":-68,"snr":6.5,"rf_chain":0}]},"downlink_url":"https://integrations.thethingsnetwork.org/ttn-eu/api/v2/down/pragma_cats_tabs/requestbin?key=ttn-account-v2.yG7tTCBkMQ8Ktg35n6rBsiUEGPIWLdf36mW_v_Mfwp8"} 2 | {"app_id":"pragma_cats_tabs","dev_id":"tabs_test1","hardware_serial":"58A0CB0000204688","port":136,"counter":86,"payload_raw":"CO4y0IUeA3hxS0A=","payload_fields":{"accuracy":16,"bytes":"CO4y0IUeA3hxS0A=","capacity":93.33333333333333,"gnss_fix":false,"latitude":52.331984,"longitude":4.944248,"port":136,"temperature":18,"voltage":3.9},"metadata":{"time":"2020-12-25T14:37:05.227013067Z","frequency":868.1,"modulation":"LORA","data_rate":"SF12BW125","coding_rate":"4/5","gateways":[{"gtw_id":"eui-58a0cbfffe802a34","timestamp":414927740,"time":"2020-12-25T14:37:05.314002037Z","channel":0,"rssi":-82,"snr":7.5,"rf_chain":0}]},"downlink_url":"https://integrations.thethingsnetwork.org/ttn-eu/api/v2/down/pragma_cats_tabs/requestbin?key=ttn-account-v2.yG7tTCBkMQ8Ktg35n6rBsiUEGPIWLdf36mW_v_Mfwp8"} 3 | {"app_id":"pragma_cats_tabs","dev_id":"tabs_test1","hardware_serial":"58A0CB0000204688","port":136,"counter":87,"payload_raw":"CN430IUeA3hxS0A=","payload_fields":{"accuracy":16,"bytes":"CN430IUeA3hxS0A=","capacity":86.66666666666667,"gnss_fix":false,"latitude":52.331984,"longitude":4.944248,"port":136,"temperature":23,"voltage":3.9},"metadata":{"time":"2020-12-25T14:39:33.593252572Z","frequency":868.3,"modulation":"LORA","data_rate":"SF12BW125","coding_rate":"4/5","gateways":[{"gtw_id":"2018071301","timestamp":716984404,"time":"2020-12-25T15:02:15Z","channel":0,"rssi":-115,"snr":-14,"rf_chain":0},{"gtw_id":"eui-58a0cbfffe802a34","timestamp":563317836,"time":"2020-12-25T14:39:33.717375993Z","channel":0,"rssi":-93,"snr":6.75,"rf_chain":0}]},"downlink_url":"https://integrations.thethingsnetwork.org/ttn-eu/api/v2/down/pragma_cats_tabs/requestbin?key=ttn-account-v2.yG7tTCBkMQ8Ktg35n6rBsiUEGPIWLdf36mW_v_Mfwp8"} 4 | {"app_id":"pragma_cats_tabs","dev_id":"tabs_test1","hardware_serial":"58A0CB0000204688","port":136,"counter":88,"payload_raw":"AN44BoseAz11S+A=","payload_fields":{"accuracy":512,"bytes":"AN44BoseAz11S+A=","capacity":86.66666666666667,"gnss_fix":true,"latitude":52.333318,"longitude":4.945213,"port":136,"temperature":24,"voltage":3.9},"metadata":{"time":"2020-12-25T14:42:01.976318616Z","frequency":868.5,"modulation":"LORA","data_rate":"SF12BW125","coding_rate":"4/5","gateways":[{"gtw_id":"2018071301","timestamp":865369180,"time":"2020-12-25T15:04:44Z","channel":0,"rssi":-117,"snr":-13.5,"rf_chain":0},{"gtw_id":"eui-58a0cbfffe802a34","timestamp":711704380,"time":"2020-12-25T14:42:02.09288907Z","channel":0,"rssi":-119,"snr":-10.5,"rf_chain":0}]},"downlink_url":"https://integrations.thethingsnetwork.org/ttn-eu/api/v2/down/pragma_cats_tabs/requestbin?key=ttn-account-v2.yG7tTCBkMQ8Ktg35n6rBsiUEGPIWLdf36mW_v_Mfwp8"} 5 | {"app_id":"pragma_cats_tabs","dev_id":"tabs_test1","hardware_serial":"58A0CB0000204688","port":136,"counter":89,"payload_raw":"AM44GoQeA2d4S4A=","payload_fields":{"accuracy":64,"bytes":"AM44GoQeA2d4S4A=","capacity":80,"gnss_fix":true,"latitude":52.331546,"longitude":4.946023,"port":136,"temperature":24,"voltage":3.9},"metadata":{"time":"2020-12-25T14:44:30.364150278Z","frequency":868.1,"modulation":"LORA","data_rate":"SF12BW125","coding_rate":"4/5","gateways":[{"gtw_id":"2018071301","timestamp":1013754924,"time":"2020-12-25T15:07:12Z","channel":0,"rssi":-113,"snr":-5.5,"rf_chain":0}]},"downlink_url":"https://integrations.thethingsnetwork.org/ttn-eu/api/v2/down/pragma_cats_tabs/requestbin?key=ttn-account-v2.yG7tTCBkMQ8Ktg35n6rBsiUEGPIWLdf36mW_v_Mfwp8"} 6 | {"app_id":"pragma_cats_tabs","dev_id":"tabs_test1","hardware_serial":"58A0CB0000204688","port":136,"counter":90,"payload_raw":"AN45xIceAx13S4A=","payload_fields":{"accuracy":64,"bytes":"AN45xIceAx13S4A=","capacity":86.66666666666667,"gnss_fix":true,"latitude":52.332484,"longitude":4.945693,"port":136,"temperature":25,"voltage":3.9},"metadata":{"time":"2020-12-25T14:46:58.752977644Z","frequency":868.3,"modulation":"LORA","data_rate":"SF12BW125","coding_rate":"4/5","gateways":[{"gtw_id":"2018071301","timestamp":1162147628,"time":"2020-12-25T15:09:41Z","channel":0,"rssi":-113,"snr":-12,"rf_chain":0}]},"downlink_url":"https://integrations.thethingsnetwork.org/ttn-eu/api/v2/down/pragma_cats_tabs/requestbin?key=ttn-account-v2.yG7tTCBkMQ8Ktg35n6rBsiUEGPIWLdf36mW_v_Mfwp8"} 7 | {"app_id":"pragma_cats_tabs","dev_id":"tabs_test1","hardware_serial":"58A0CB0000204688","port":136,"counter":91,"payload_raw":"AM45o5EeA1B2S0A=","payload_fields":{"accuracy":16,"bytes":"AM45o5EeA1B2S0A=","capacity":80,"gnss_fix":true,"latitude":52.335011,"longitude":4.945488,"port":136,"temperature":25,"voltage":3.9},"metadata":{"time":"2020-12-25T14:49:27.180271855Z","frequency":868.5,"modulation":"LORA","data_rate":"SF12BW125","coding_rate":"4/5","gateways":[{"gtw_id":"eui-58a0cbfffe802a34","timestamp":1156875580,"time":"2020-12-25T14:49:27.273927927Z","channel":0,"rssi":-115,"snr":-7,"rf_chain":0}]},"downlink_url":"https://integrations.thethingsnetwork.org/ttn-eu/api/v2/down/pragma_cats_tabs/requestbin?key=ttn-account-v2.yG7tTCBkMQ8Ktg35n6rBsiUEGPIWLdf36mW_v_Mfwp8"} 8 | {"app_id":"pragma_cats_tabs","dev_id":"tabs_test1","hardware_serial":"58A0CB0000204688","port":136,"counter":92,"payload_raw":"AM45QZkeA+JtSyA=","payload_fields":{"accuracy":8,"bytes":"AM45QZkeA+JtSyA=","capacity":80,"gnss_fix":true,"latitude":52.336961,"longitude":4.94333,"port":136,"temperature":25,"voltage":3.9},"metadata":{"time":"2020-12-25T14:51:55.700289015Z","frequency":868.1,"modulation":"LORA","data_rate":"SF12BW125","coding_rate":"4/5","gateways":[{"gtw_id":"eui-0000024b080309c2","timestamp":1435542412,"time":"2020-12-25T14:51:55.489797Z","channel":0,"rssi":-93,"snr":-5.8,"rf_chain":0,"latitude":52.33645,"longitude":4.88754,"altitude":75},{"gtw_id":"eui-0000024b08030916","timestamp":1214566532,"time":"2020-12-25T14:51:55.489793Z","channel":0,"rssi":-106,"snr":-10,"rf_chain":0,"latitude":52.33631,"longitude":4.88729,"altitude":71}]},"downlink_url":"https://integrations.thethingsnetwork.org/ttn-eu/api/v2/down/pragma_cats_tabs/requestbin?key=ttn-account-v2.yG7tTCBkMQ8Ktg35n6rBsiUEGPIWLdf36mW_v_Mfwp8"} 9 | {"app_id":"pragma_cats_tabs","dev_id":"tabs_test1","hardware_serial":"58A0CB0000204688","port":136,"counter":98,"payload_raw":"AL45h6seA2c6SwA=","payload_fields":{"accuracy":4,"bytes":"AL45h6seA2c6SwA=","capacity":73.33333333333333,"gnss_fix":true,"latitude":52.341639,"longitude":4.930151,"port":136,"temperature":25,"voltage":3.9},"metadata":{"time":"2020-12-25T15:06:46.162343365Z","frequency":868.1,"modulation":"LORA","data_rate":"SF12BW125","coding_rate":"4/5","gateways":[{"gtw_id":"eui-0000024b08030916","timestamp":2104904532,"time":"2020-12-25T15:06:45.827752Z","channel":0,"rssi":-103,"snr":-1.5,"rf_chain":0,"latitude":52.33627,"longitude":4.88732,"altitude":52},{"gtw_id":"eui-0000024b080309c2","timestamp":2325880556,"time":"2020-12-25T15:06:45.827755Z","channel":0,"rssi":-96,"snr":-0.2,"rf_chain":0,"latitude":52.33646,"longitude":4.8876,"altitude":72}]},"downlink_url":"https://integrations.thethingsnetwork.org/ttn-eu/api/v2/down/pragma_cats_tabs/requestbin?key=ttn-account-v2.yG7tTCBkMQ8Ktg35n6rBsiUEGPIWLdf36mW_v_Mfwp8"} 10 | {"app_id":"pragma_cats_tabs","dev_id":"tabs_test1","hardware_serial":"58A0CB0000204688","port":136,"counter":128,"payload_raw":"AK47yLIeAwJ+SwA=","payload_fields":{"accuracy":4,"bytes":"AK47yLIeAwJ+SwA=","capacity":66.66666666666666,"gnss_fix":true,"latitude":52.343496,"longitude":4.947458,"port":136,"temperature":27,"voltage":3.9},"metadata":{"time":"2020-12-25T16:20:57.427836088Z","frequency":868.1,"modulation":"LORA","data_rate":"SF12BW125","coding_rate":"4/5","gateways":[{"gtw_id":"2018071301","timestamp":2505848404,"time":"2020-12-25T16:43:39Z","channel":0,"rssi":-117,"snr":-11.75,"rf_chain":0}]},"downlink_url":"https://integrations.thethingsnetwork.org/ttn-eu/api/v2/down/pragma_cats_tabs/requestbin?key=ttn-account-v2.yG7tTCBkMQ8Ktg35n6rBsiUEGPIWLdf36mW_v_Mfwp8"} 11 | {"app_id":"pragma_cats_tabs","dev_id":"tabs_test1","hardware_serial":"58A0CB0000204688","port":136,"counter":135,"payload_raw":"AH45s5YeAyGkSwA=","payload_fields":{"accuracy":4,"bytes":"AH45s5YeAyGkSwA=","capacity":46.666666666666664,"gnss_fix":true,"latitude":52.336307,"longitude":4.957217,"port":136,"temperature":25,"voltage":3.9},"metadata":{"time":"2020-12-25T16:38:16.150984258Z","frequency":868.3,"modulation":"LORA","data_rate":"SF12BW125","coding_rate":"4/5","gateways":[{"gtw_id":"2018071301","timestamp":3544567660,"time":"2020-12-25T17:00:58Z","channel":0,"rssi":-114,"snr":-8.75,"rf_chain":0}]},"downlink_url":"https://integrations.thethingsnetwork.org/ttn-eu/api/v2/down/pragma_cats_tabs/requestbin?key=ttn-account-v2.yG7tTCBkMQ8Ktg35n6rBsiUEGPIWLdf36mW_v_Mfwp8"} 12 | {"app_id":"pragma_cats_tabs","dev_id":"tabs_test1","hardware_serial":"58A0CB0000204688","port":136,"counter":136,"payload_raw":"AG45kJQeA7qeSwA=","payload_fields":{"accuracy":4,"bytes":"AG45kJQeA7qeSwA=","capacity":40,"gnss_fix":true,"latitude":52.33576,"longitude":4.955834,"port":136,"temperature":25,"voltage":3.9},"metadata":{"time":"2020-12-25T16:40:44.562337609Z","frequency":868.5,"modulation":"LORA","data_rate":"SF12BW125","coding_rate":"4/5","gateways":[{"gtw_id":"eui-58a0cbfffe802a34","timestamp":3539295004,"time":"2020-12-25T16:40:44.677113056Z","channel":0,"rssi":-117,"snr":-12.5,"rf_chain":0}]},"downlink_url":"https://integrations.thethingsnetwork.org/ttn-eu/api/v2/down/pragma_cats_tabs/requestbin?key=ttn-account-v2.yG7tTCBkMQ8Ktg35n6rBsiUEGPIWLdf36mW_v_Mfwp8"} 13 | {"app_id":"pragma_cats_tabs","dev_id":"tabs_test1","hardware_serial":"58A0CB0000204688","port":136,"counter":137,"payload_raw":"AG45uY4eA32YSwA=","payload_fields":{"accuracy":4,"bytes":"AG45uY4eA32YSwA=","capacity":40,"gnss_fix":true,"latitude":52.334265,"longitude":4.954237,"port":136,"temperature":25,"voltage":3.9},"metadata":{"time":"2020-12-25T16:43:12.91408492Z","frequency":868.1,"modulation":"LORA","data_rate":"SF12BW125","coding_rate":"4/5","gateways":[{"gtw_id":"2018071301","timestamp":3841332228,"time":"2020-12-25T17:05:55Z","channel":0,"rssi":-119,"snr":-9.75,"rf_chain":0},{"gtw_id":"eui-58a0cbfffe802a34","timestamp":3687679132,"time":"2020-12-25T16:43:13.066694974Z","channel":0,"rssi":-117,"snr":-1.75,"rf_chain":0}]},"downlink_url":"https://integrations.thethingsnetwork.org/ttn-eu/api/v2/down/pragma_cats_tabs/requestbin?key=ttn-account-v2.yG7tTCBkMQ8Ktg35n6rBsiUEGPIWLdf36mW_v_Mfwp8"} 14 | {"app_id":"pragma_cats_tabs","dev_id":"tabs_test1","hardware_serial":"58A0CB0000204688","port":136,"counter":138,"payload_raw":"AG45UooeA1iPSwA=","payload_fields":{"accuracy":4,"bytes":"AG45UooeA1iPSwA=","capacity":40,"gnss_fix":true,"latitude":52.333138,"longitude":4.951896,"port":136,"temperature":25,"voltage":3.9},"metadata":{"time":"2020-12-25T16:45:41.326112959Z","frequency":868.3,"modulation":"LORA","data_rate":"SF12BW125","coding_rate":"4/5","gateways":[{"gtw_id":"eui-58a0cbfffe802a34","timestamp":3836071132,"time":"2020-12-25T16:45:41.441497087Z","channel":0,"rssi":-117,"snr":-2,"rf_chain":0}]},"downlink_url":"https://integrations.thethingsnetwork.org/ttn-eu/api/v2/down/pragma_cats_tabs/requestbin?key=ttn-account-v2.yG7tTCBkMQ8Ktg35n6rBsiUEGPIWLdf36mW_v_Mfwp8"} 15 | {"app_id":"pragma_cats_tabs","dev_id":"tabs_test1","hardware_serial":"58A0CB0000204688","port":136,"counter":139,"payload_raw":"AI45D4geA02JSwA=","payload_fields":{"accuracy":4,"bytes":"AI45D4geA02JSwA=","capacity":53.333333333333336,"gnss_fix":true,"latitude":52.332559,"longitude":4.950349,"port":136,"temperature":25,"voltage":3.9},"metadata":{"time":"2020-12-25T16:48:09.724075345Z","frequency":868.5,"modulation":"LORA","data_rate":"SF12BW125","coding_rate":"4/5","gateways":[{"gtw_id":"eui-58a0cbfffe802a34","timestamp":3984459956,"time":"2020-12-25T16:48:09.840265989Z","channel":0,"rssi":-119,"snr":-6,"rf_chain":0}]},"downlink_url":"https://integrations.thethingsnetwork.org/ttn-eu/api/v2/down/pragma_cats_tabs/requestbin?key=ttn-account-v2.yG7tTCBkMQ8Ktg35n6rBsiUEGPIWLdf36mW_v_Mfwp8"} 16 | {"app_id":"pragma_cats_tabs","dev_id":"tabs_test1","hardware_serial":"58A0CB0000204688","port":136,"counter":140,"payload_raw":"AG45boQeA019SwA=","payload_fields":{"accuracy":4,"bytes":"AG45boQeA019SwA=","capacity":40,"gnss_fix":true,"latitude":52.33163,"longitude":4.947277,"port":136,"temperature":25,"voltage":3.9},"metadata":{"time":"2020-12-25T16:50:38.115320305Z","frequency":868.1,"modulation":"LORA","data_rate":"SF12BW125","coding_rate":"4/5","gateways":[{"gtw_id":"eui-58a0cbfffe802a34","timestamp":4132850164,"time":"2020-12-25T16:50:38.231086969Z","channel":0,"rssi":-85,"snr":9,"rf_chain":0}]},"downlink_url":"https://integrations.thethingsnetwork.org/ttn-eu/api/v2/down/pragma_cats_tabs/requestbin?key=ttn-account-v2.yG7tTCBkMQ8Ktg35n6rBsiUEGPIWLdf36mW_v_Mfwp8"} 17 | {"app_id":"pragma_cats_tabs","dev_id":"tabs_test1","hardware_serial":"58A0CB0000204688","port":136,"counter":141,"payload_raw":"AF05rIMeA/d0SwA=","payload_fields":{"accuracy":4,"bytes":"AF05rIMeA/d0SwA=","capacity":33.33333333333333,"gnss_fix":true,"latitude":52.331436,"longitude":4.945143,"port":136,"temperature":25,"voltage":3.8},"metadata":{"time":"2020-12-25T16:53:06.472453037Z","frequency":868.3,"modulation":"LORA","data_rate":"SF12BW125","coding_rate":"4/5","gateways":[{"gtw_id":"eui-58a0cbfffe802a34","timestamp":4281211308,"time":"2020-12-25T16:53:06.588855028Z","channel":0,"rssi":-87,"snr":7.5,"rf_chain":0}]},"downlink_url":"https://integrations.thethingsnetwork.org/ttn-eu/api/v2/down/pragma_cats_tabs/requestbin?key=ttn-account-v2.yG7tTCBkMQ8Ktg35n6rBsiUEGPIWLdf36mW_v_Mfwp8"} 18 | {"app_id":"pragma_cats_tabs","dev_id":"tabs_test1","hardware_serial":"58A0CB0000204688","port":136,"counter":142,"payload_raw":"AG45ZIUeAxdwSwA=","payload_fields":{"accuracy":4,"bytes":"AG45ZIUeAxdwSwA=","capacity":40,"gnss_fix":true,"latitude":52.331876,"longitude":4.943895,"port":136,"temperature":25,"voltage":3.9},"metadata":{"time":"2020-12-25T16:55:34.85908938Z","frequency":868.5,"modulation":"LORA","data_rate":"SF12BW125","coding_rate":"4/5","gateways":[{"gtw_id":"eui-58a0cbfffe802a34","timestamp":134635756,"time":"2020-12-25T16:55:34.976077079Z","channel":0,"rssi":-107,"snr":-2.25,"rf_chain":0}]},"downlink_url":"https://integrations.thethingsnetwork.org/ttn-eu/api/v2/down/pragma_cats_tabs/requestbin?key=ttn-account-v2.yG7tTCBkMQ8Ktg35n6rBsiUEGPIWLdf36mW_v_Mfwp8"} 19 | {"app_id":"pragma_cats_tabs","dev_id":"tabs_test1","hardware_serial":"58A0CB0000204688","port":136,"counter":143,"payload_raw":"AH45qoceAyduSwA=","payload_fields":{"accuracy":4,"bytes":"AH45qoceAyduSwA=","capacity":46.666666666666664,"gnss_fix":true,"latitude":52.332458,"longitude":4.943399,"port":136,"temperature":25,"voltage":3.9},"metadata":{"time":"2020-12-25T16:58:03.245069274Z","frequency":868.1,"modulation":"LORA","data_rate":"SF12BW125","coding_rate":"4/5","gateways":[{"gtw_id":"eui-58a0cbfffe802a34","timestamp":283018028,"time":"2020-12-25T16:58:03.362689018Z","channel":0,"rssi":-114,"snr":-2,"rf_chain":0}]},"downlink_url":"https://integrations.thethingsnetwork.org/ttn-eu/api/v2/down/pragma_cats_tabs/requestbin?key=ttn-account-v2.yG7tTCBkMQ8Ktg35n6rBsiUEGPIWLdf36mW_v_Mfwp8"} 20 | {"app_id":"pragma_cats_tabs","dev_id":"tabs_test1","hardware_serial":"58A0CB0000204688","port":136,"counter":144,"payload_raw":"AG45mogeA6ZiSwA=","payload_fields":{"accuracy":4,"bytes":"AG45mogeA6ZiSwA=","capacity":40,"gnss_fix":true,"latitude":52.332698,"longitude":4.940454,"port":136,"temperature":25,"voltage":3.9},"metadata":{"time":"2020-12-25T17:00:31.636125824Z","frequency":868.3,"modulation":"LORA","data_rate":"SF12BW125","coding_rate":"4/5","gateways":[{"gtw_id":"eui-58a0cbfffe802a34","timestamp":431411724,"time":"2020-12-25T17:00:31.754385948Z","channel":0,"rssi":-92,"snr":8.25,"rf_chain":0}]},"downlink_url":"https://integrations.thethingsnetwork.org/ttn-eu/api/v2/down/pragma_cats_tabs/requestbin?key=ttn-account-v2.yG7tTCBkMQ8Ktg35n6rBsiUEGPIWLdf36mW_v_Mfwp8"} 21 | {"app_id":"pragma_cats_tabs","dev_id":"tabs_test1","hardware_serial":"58A0CB0000204688","port":136,"counter":145,"payload_raw":"AG45J4keA71kSwA=","payload_fields":{"accuracy":4,"bytes":"AG45J4keA71kSwA=","capacity":40,"gnss_fix":true,"latitude":52.332839,"longitude":4.940989,"port":136,"temperature":25,"voltage":3.9},"metadata":{"time":"2020-12-25T17:03:00.023328259Z","frequency":868.5,"modulation":"LORA","data_rate":"SF12BW125","coding_rate":"4/5","gateways":[{"gtw_id":"eui-58a0cbfffe802a34","timestamp":579784892,"time":"2020-12-25T17:03:00.141531944Z","channel":0,"rssi":-76,"snr":6.25,"rf_chain":0}]},"downlink_url":"https://integrations.thethingsnetwork.org/ttn-eu/api/v2/down/pragma_cats_tabs/requestbin?key=ttn-account-v2.yG7tTCBkMQ8Ktg35n6rBsiUEGPIWLdf36mW_v_Mfwp8"} 22 | {"app_id":"pragma_cats_tabs","dev_id":"tabs_test1","hardware_serial":"58A0CB0000204688","port":136,"counter":146,"payload_raw":"AG45poYeA0FvSwA=","payload_fields":{"accuracy":4,"bytes":"AG45poYeA0FvSwA=","capacity":40,"gnss_fix":true,"latitude":52.332198,"longitude":4.943681,"port":136,"temperature":25,"voltage":3.9},"metadata":{"time":"2020-12-25T17:05:28.327662481Z","frequency":868.1,"modulation":"LORA","data_rate":"SF12BW125","coding_rate":"4/5","gateways":[{"gtw_id":"eui-58a0cbfffe802a34","timestamp":728096028,"time":"2020-12-25T17:05:28.44731307Z","channel":0,"rssi":-65,"snr":6.75,"rf_chain":0}]},"downlink_url":"https://integrations.thethingsnetwork.org/ttn-eu/api/v2/down/pragma_cats_tabs/requestbin?key=ttn-account-v2.yG7tTCBkMQ8Ktg35n6rBsiUEGPIWLdf36mW_v_Mfwp8"} 23 | {"app_id":"pragma_cats_tabs","dev_id":"tabs_test1","hardware_serial":"58A0CB0000204688","port":136,"counter":147,"payload_raw":"AH43ZIUeA4pwSyA=","payload_fields":{"accuracy":8,"bytes":"AH43ZIUeA4pwSyA=","capacity":46.666666666666664,"gnss_fix":true,"latitude":52.331876,"longitude":4.94401,"port":136,"temperature":23,"voltage":3.9},"metadata":{"time":"2020-12-25T17:07:56.637436024Z","frequency":868.3,"modulation":"LORA","data_rate":"SF12BW125","coding_rate":"4/5","gateways":[{"gtw_id":"eui-58a0cbfffe802a34","timestamp":876395164,"time":"2020-12-25T17:07:56.756975889Z","channel":0,"rssi":-59,"snr":7.25,"rf_chain":0}]},"downlink_url":"https://integrations.thethingsnetwork.org/ttn-eu/api/v2/down/pragma_cats_tabs/requestbin?key=ttn-account-v2.yG7tTCBkMQ8Ktg35n6rBsiUEGPIWLdf36mW_v_Mfwp8"} 24 | {"app_id":"pragma_cats_tabs","dev_id":"tabs_test1","hardware_serial":"58A0CB0000204688","port":136,"counter":148,"payload_raw":"CI4zxoUeA8lwSyA=","payload_fields":{"accuracy":8,"bytes":"CI4zxoUeA8lwSyA=","capacity":53.333333333333336,"gnss_fix":false,"latitude":52.331974,"longitude":4.944073,"port":136,"temperature":19,"voltage":3.9},"metadata":{"time":"2020-12-25T17:29:59.089143019Z","frequency":868.5,"modulation":"LORA","data_rate":"SF12BW125","coding_rate":"4/5","gateways":[{"gtw_id":"eui-58a0cbfffe802a34","timestamp":2198850692,"time":"2020-12-25T17:29:59.212553977Z","channel":0,"rssi":-62,"snr":7.5,"rf_chain":0}]},"downlink_url":"https://integrations.thethingsnetwork.org/ttn-eu/api/v2/down/pragma_cats_tabs/requestbin?key=ttn-account-v2.yG7tTCBkMQ8Ktg35n6rBsiUEGPIWLdf36mW_v_Mfwp8"} 25 | {"app_id":"pragma_cats_tabs","dev_id":"tabs_test1","hardware_serial":"58A0CB0000204688","port":136,"counter":149,"payload_raw":"CI41xoUeA8lwSyA=","payload_fields":{"accuracy":8,"bytes":"CI41xoUeA8lwSyA=","capacity":53.333333333333336,"gnss_fix":false,"latitude":52.331974,"longitude":4.944073,"port":136,"temperature":21,"voltage":3.9},"metadata":{"time":"2020-12-25T17:32:27.376528921Z","frequency":868.1,"modulation":"LORA","data_rate":"SF12BW125","coding_rate":"4/5","gateways":[{"gtw_id":"eui-58a0cbfffe802a34","timestamp":2347153036,"time":"2020-12-25T17:32:27.500372886Z","channel":0,"rssi":-66,"snr":7.25,"rf_chain":0}]},"downlink_url":"https://integrations.thethingsnetwork.org/ttn-eu/api/v2/down/pragma_cats_tabs/requestbin?key=ttn-account-v2.yG7tTCBkMQ8Ktg35n6rBsiUEGPIWLdf36mW_v_Mfwp8"} 26 | {"app_id":"pragma_cats_tabs","dev_id":"tabs_test1","hardware_serial":"58A0CB0000204688","port":136,"counter":150,"payload_raw":"CJ4zxoUeA8lwSyA=","payload_fields":{"accuracy":8,"bytes":"CJ4zxoUeA8lwSyA=","capacity":60,"gnss_fix":false,"latitude":52.331974,"longitude":4.944073,"port":136,"temperature":19,"voltage":3.9},"metadata":{"time":"2020-12-25T23:19:05.98532115Z","frequency":868.3,"modulation":"LORA","data_rate":"SF12BW125","coding_rate":"4/5","gateways":[{"gtw_id":"eui-58a0cbfffe802a34","timestamp":1648928036,"time":"2020-12-25T23:19:05.955276966Z","channel":0,"rssi":-65,"snr":8.25,"rf_chain":0}]},"downlink_url":"https://integrations.thethingsnetwork.org/ttn-eu/api/v2/down/pragma_cats_tabs/requestbin?key=ttn-account-v2.yG7tTCBkMQ8Ktg35n6rBsiUEGPIWLdf36mW_v_Mfwp8"} 27 | --------------------------------------------------------------------------------