├── 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 |
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 |
--------------------------------------------------------------------------------