Compare commits

26 Commits

Author SHA256 Message Date
c0ac71ebba fix: edit wrong .gitignore :D 2025-03-22 10:22:19 +01:00
10e8e14cd6 feat: add docker compose to gitignore 2025-03-22 10:20:42 +01:00
a9c8525e6e Merge pull request 'Add timestamps of locations providers' (#18) from feat/timestamps into main
Reviewed-on: #18
Reviewed-by: Philipp Schweizer <Philipp.schw@directbox.com>
2025-02-14 21:22:57 +01:00
39c07fcef0 refactor: only search once for wifiMessage 2025-02-14 21:06:47 +01:00
52d521a6ad rename var 2025-02-08 21:24:40 +01:00
d3848ac1aa Merge branch 'main' into feat/timestamps 2025-02-08 20:54:50 +01:00
93f0c71a6c set wifi timestamp 2025-02-08 20:54:06 +01:00
5e4fd59148 refactor to use either timestamp when one is undefined 2025-02-02 20:10:29 +01:00
62a2dc2c4a fix gnss timestamp 2025-01-29 23:03:48 +01:00
59dc57a618 set gnss timestamp in location 2025-01-28 22:12:48 +01:00
452589d11d add timestamps in DB model 2025-01-28 21:55:54 +01:00
d04bdb3ac1 Merge pull request 'Add Prometheus metrics endpoint' (#14) from feat/prometheus into main
Reviewed-on: #14
Reviewed-by: Philipp Schweizer <Philipp.schw@directbox.com>
2025-01-26 10:03:28 +01:00
a745aaf9d0 add timestamps to DB model 2025-01-25 21:51:42 +01:00
51112b5870 update readme 2025-01-25 19:14:18 +01:00
262b4718fc Merge branch 'main' into feat/prometheus 2025-01-25 19:10:09 +01:00
4d0e84b84a move metrics definition to service 2025-01-25 19:08:30 +01:00
f969b0a4c0 use Promise.all 2025-01-25 18:50:25 +01:00
49fe1b4ce3 README.md aktualisiert 2025-01-19 13:35:53 +01:00
34167c4d99 export metric for gnss locations 2025-01-18 19:36:44 +01:00
79580e16b7 Merge branch 'main' into feat/prometheus 2025-01-18 19:01:11 +01:00
abf6b9af82 add metric TTN Uplinks 2025-01-16 21:50:00 +01:00
85e3509731 embarrassing 2025-01-15 17:32:31 +01:00
8a4eadefcb use db wifi_location as metric source 2025-01-14 21:03:44 +01:00
e8154d1e13 wigle api request counter 2025-01-11 23:38:44 +01:00
9b00853d5a use prom-client 2025-01-11 22:59:07 +01:00
574a63b2a3 add metrics api 2025-01-11 21:59:11 +01:00
16 changed files with 256 additions and 23 deletions

1
.gitignore vendored Normal file
View File

@ -0,0 +1 @@
docker-compose.yml

View File

@ -11,10 +11,11 @@ We recommend using [The Things Network](https://www.thethingsnetwork.org/) (TTN)
- [Database](#database)
- [Configuration](#configuration)
2. [TTN Integration](#ttn-integration)
3. [Add a Location Tracker](#add-a-location-tracker)
3. [Prometheus Metrics](#prometheus-metrics)
4. [Add a Location Tracker](#add-a-location-tracker)
- [Onboard SenseCAP T1000-B](#onboard-sensecap-t1000-b)
- [Register SenseCAP T1000-B](#register-sensecap-t1000-b)
4. [Testing](#testing)
5. [Testing](#testing)
- [Testing Webhook](#testing-webhook)
- [Emulating Wigle API](#emulating-wigle-api)
@ -49,6 +50,9 @@ Add a addidtional header:
- Type: `authorization`
- Value: `Bearer your-very-secure-token-from-the-env-file`
### Prometheus Metrics
Use `https://your.domain.tld/api/metrics` to retrieve useful insides for monitoring.
## Add a Location Tracker
We use the [SenseCAP T1000-B](https://www.seeedstudio.com/SenseCAP-Card-Tracker-T1000-B-p-5698.html) from seeedstudio because of the fair price and multiple location providers. However, you can use any LoRaWAN-enabled tracker that is compatible with TTN and supports the required payload fields.
@ -60,7 +64,7 @@ We use the [SenseCAP T1000-B](https://www.seeedstudio.com/SenseCAP-Card-Tracker-
5. In the `Settings` tab select `The Things Network` as Platform under `LoRa`
6. Copy `Device EUI`, `AppEUI` and `AppKey`
7. Save LoRa settings
8. Under `Geolocation` select Geolocation Strategy as `Bluetooth+Wi-Fi+GNSS`
8. Under `Geolocation` select Geolocation Strategy as `GNSS+Wi-Fi`
9. Set `GNSS Max Scan Time (s)` to 120
10. Save Geolocation settings
11. Exit App with return arrow to trigger re-init of SenseCAP T1000-B

5
server/.gitignore vendored
View File

@ -308,7 +308,4 @@ cython_debug/
# Built Visual Studio Code Extensions
*.vsix
config.py
#docker
docker-compose.yml
config.py

View File

@ -14,6 +14,7 @@
"express": "^4.21.2",
"http-status-codes": "^2.3.0",
"mariadb": "^3.4.0",
"prom-client": "^15.1.3",
"reflect-metadata": "^0.2.2",
"sequelize": "^6.37.5",
"swagger-jsdoc": "^6.2.8",
@ -120,6 +121,15 @@
"integrity": "sha512-4JQNk+3mVzK3xh2rqd6RB4J46qUR19azEHBneZyTZM+c456qOrbbM/5xcR8huNCCcbVt7+UmizG6GuUvPvKUYg==",
"license": "MIT"
},
"node_modules/@opentelemetry/api": {
"version": "1.9.0",
"resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz",
"integrity": "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==",
"license": "Apache-2.0",
"engines": {
"node": ">=8.0.0"
}
},
"node_modules/@scarf/scarf": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/@scarf/scarf/-/scarf-1.4.0.tgz",
@ -386,6 +396,12 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/bintrees": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/bintrees/-/bintrees-1.0.2.tgz",
"integrity": "sha512-VOMgTMwjAaUG580SXn3LacVgjurrbMme7ZZNYGSSV7mmtY6QQRh0Eg3pwIcntQ77DErK1L0NxkbetjcoXzVwKw==",
"license": "MIT"
},
"node_modules/body-parser": {
"version": "1.20.3",
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.3.tgz",
@ -1434,6 +1450,19 @@
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/prom-client": {
"version": "15.1.3",
"resolved": "https://registry.npmjs.org/prom-client/-/prom-client-15.1.3.tgz",
"integrity": "sha512-6ZiOBfCywsD4k1BN9IX0uZhF+tJkV8q8llP64G5Hajs4JOeVLPCwpPVcpXy3BwYiUGgyJzsJJQeOIv7+hDSq8g==",
"license": "Apache-2.0",
"dependencies": {
"@opentelemetry/api": "^1.4.0",
"tdigest": "^0.1.1"
},
"engines": {
"node": "^16 || ^18 || >=20"
}
},
"node_modules/proxy-addr": {
"version": "2.0.7",
"resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",
@ -1873,6 +1902,15 @@
"express": ">=4.0.0 || >=5.0.0-beta"
}
},
"node_modules/tdigest": {
"version": "0.1.2",
"resolved": "https://registry.npmjs.org/tdigest/-/tdigest-0.1.2.tgz",
"integrity": "sha512-+G0LLgjjo9BZX2MfdvPfH+MKLCrxlXSYec5DaPYP1fe6Iyhf0/fSmJ0bFiZ1F8BT6cGXl2LpltQptzjXKWEkKA==",
"license": "MIT",
"dependencies": {
"bintrees": "1.0.2"
}
},
"node_modules/to-regex-range": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",

View File

@ -24,6 +24,7 @@
"express": "^4.21.2",
"http-status-codes": "^2.3.0",
"mariadb": "^3.4.0",
"prom-client": "^15.1.3",
"reflect-metadata": "^0.2.2",
"sequelize": "^6.37.5",
"swagger-jsdoc": "^6.2.8",

View File

@ -5,7 +5,7 @@ CREATE TABLE IF NOT EXISTS lp_ttn_end_device_uplinks (
dev_eui VARCHAR(255),
join_eui VARCHAR(255),
dev_addr VARCHAR(255),
received_at_utc DATE,
received_at_utc DATE NOT NULL,
battery NUMERIC,
created_at_utc TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at_utc TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
@ -16,6 +16,7 @@ CREATE TABLE IF NOT EXISTS wifi_scan (
lp_ttn_end_device_uplinks_id UUID,
mac VARCHAR(255),
rssi NUMERIC,
scanned_at_utc TIMESTAMP NOT NULL,
created_at_utc TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at_utc TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
FOREIGN KEY (lp_ttn_end_device_uplinks_id) REFERENCES lp_ttn_end_device_uplinks(lp_ttn_end_device_uplinks_id)
@ -63,6 +64,7 @@ CREATE TABLE IF NOT EXISTS location (
gnss_longitude DOUBLE,
ttn_gw_latitude DOUBLE,
ttn_gw_longitude DOUBLE,
gnss_location_at_utc TIMESTAMP,
created_at_utc TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at_utc TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
FOREIGN KEY (lp_ttn_end_device_uplinks_id) REFERENCES lp_ttn_end_device_uplinks(lp_ttn_end_device_uplinks_id)

View File

@ -0,0 +1,63 @@
import express, { Request, Response } from "express";
import { container } from "tsyringe";
import { Counter, Gauge, collectDefaultMetrics, register } from "prom-client";
import { LocationService } from "../services/locationService";
import { WifiLocationService } from "../services/wifiLocationService";
import { TtnGatewayReceptionService } from "../services/ttnGatewayReceptionService";
import { MetricsService } from "../services/metricsService";
const router = express.Router();
const locationService = container.resolve(LocationService);
const wifiLocationService = container.resolve(WifiLocationService);
const ttnGatewayReceptionService = container.resolve(TtnGatewayReceptionService);
const metricsService = container.resolve(MetricsService);
const requestCounter = metricsService.getRequestCounter();
const locationsTotal = metricsService.getLocationsTotal();
const gnssLocationsTotal = metricsService.getGnssLocationsTotal()
const wifiLocationTotal = metricsService.getWifiLocationTotal();
const wifiLocationNotResolvable = metricsService.getWifiLocationResolvable();
const wifiLocationRequestLimitExceeded = metricsService.getLWifiLocationRequestLimitExceeded();
const ttnGatewayReceptions = metricsService.getTnnGatewayReceptions();
// Define the metrics endpoint
router.get("/", async (req: Request, res: Response) => {
try {
const [
allLocations,
gnssLocations,
allWifiLocations,
wifiLocationsNotResolvable,
wifiLocationsRequestLimitExceeded,
allTtnGatewayReceptions
] = await Promise.all([
locationService.getAllLocations(),
locationService.getAllGnssLocations(),
wifiLocationService.getAllWifiLocations(),
wifiLocationService.getAllWifiLocationsByNotResolvable(),
wifiLocationService.getAllWifiLocationsByRequestLimitExceeded(),
ttnGatewayReceptionService.getAllGatewayReceptions()
]);
locationsTotal.set(allLocations.length);
gnssLocationsTotal.set(gnssLocations.length);
wifiLocationTotal.set(allWifiLocations.length);
wifiLocationNotResolvable.set(wifiLocationsNotResolvable.length);
wifiLocationRequestLimitExceeded.set(wifiLocationsRequestLimitExceeded.length);
ttnGatewayReceptions.set(allTtnGatewayReceptions.length);
// Increment the counter with labels
requestCounter.inc({ method: req.method, route: req.route.path, status: 200 });
// Expose metrics in Prometheus format
res.set("Content-Type", register.contentType);
res.send(await register.metrics());
} catch (error) {
// Increment the counter for errors
requestCounter.inc({ method: req.method, route: req.route.path, status: 500 });
console.error("Error running metrics endpoint:", error);
res.status(500).json({ error: "Error running metrics endpoint" });
}
});
export default router;

View File

@ -42,23 +42,38 @@ router.post(
)?.measurementValue,
});
const messageData = message.uplink_message.decoded_payload?.messages[0];
const latitudeData = messageData?.find((e) => e.type === "Latitude");
const longitudeData = messageData?.find((e) => e.type === "Longitude");
const gnnsLocation = {
latitude: message.uplink_message.decoded_payload?.messages[0].find(
(e) => e.type === "Latitude"
)?.measurementValue,
longitude: message.uplink_message.decoded_payload?.messages[0].find(
(e) => e.type === "Longitude"
)?.measurementValue,
latitude: latitudeData?.measurementValue,
longitude: longitudeData?.measurementValue,
};
const gnssTimestamp = {
timestamp: latitudeData?.timestamp
? new Date(latitudeData.timestamp)
: longitudeData?.timestamp
? new Date(longitudeData.timestamp)
: undefined,
};
const wifiMessage =
message.uplink_message.decoded_payload?.messages[0].find(
(e) => e.type === "Wi-Fi Scan"
);
const wifiScans =
message.uplink_message.decoded_payload?.messages[0]
.find((e) => e.type === "Wi-Fi Scan")
?.measurementValue?.map((w) => ({
lp_ttn_end_device_uplinks_id,
mac: w.mac,
rssi: w.rssi,
})) ?? [];
wifiMessage?.measurementValue?.map((w) => ({
lp_ttn_end_device_uplinks_id,
mac: w.mac,
rssi: w.rssi,
scanned_at_utc: wifiMessage?.timestamp
? new Date(wifiMessage.timestamp)
: undefined,
})) ?? [];
const ttnGatewayReceptions = message.uplink_message.rx_metadata.map(
(g) => ({
@ -98,6 +113,7 @@ router.post(
longitude: gnnsLocation.longitude,
}
: undefined,
gnss_timestamp: gnssTimestamp.timestamp,
});
};
createDatabaseEntries().then();

View File

@ -10,6 +10,7 @@ import ttnGatewayReceptionRoutes from "./controller/ttnGatewayReceptionControlle
import wifiLocationRoutes from "./controller/wifiLocationController";
import wifiLocationHistoryRoutes from "./controller/wifiLocationHistoryController";
import wifiScanRoutes from "./controller/wifiScanController";
import metricsRoutes from "./controller/metricsController";
dotenv.config();
@ -26,6 +27,7 @@ app.use("/api/wifi-location", wifiLocationRoutes);
app.use("/api/wifi-scans", wifiScanRoutes);
app.use("/api/locations", locationRoutes);
app.use("/api/ttn", ttnRoutes);
app.use("/api/metrics", metricsRoutes);
app.listen(PORT, () => {
console.log(`🚀 Server runs here: http://localhost:${PORT}`);

View File

@ -10,6 +10,7 @@ export class Location extends Model {
public gnss_longitude!: number;
public ttn_gw_latitude!: number;
public ttn_gw_longitude!: number;
public gnss_location_at_utc!: Date;
public created_at_utc!: Date;
public updated_at_utc!: Date;
}
@ -42,6 +43,9 @@ Location.init(
ttn_gw_longitude: {
type: DataTypes.NUMBER,
},
gnss_location_at_utc: {
type: DataTypes.DATE,
},
created_at_utc: {
type: DataTypes.DATE,
defaultValue: DataTypes.NOW,

View File

@ -6,6 +6,7 @@ export class WifiScan extends Model {
public wifi_scan_id!: string;
public mac!: string;
public rssi!: number;
public scanned_at_utc!: Date;
public created_at_utc!: Date;
public updated_at_utc!: Date;
}
@ -30,6 +31,11 @@ WifiScan.init(
type: DataTypes.NUMBER,
allowNull: false,
},
scanned_at_utc: {
type: DataTypes.DATE,
defaultValue: DataTypes.NOW,
allowNull: false,
},
created_at_utc: {
type: DataTypes.DATE,
defaultValue: DataTypes.NOW,

View File

@ -1,10 +1,11 @@
import { Attributes, FindOptions } from "sequelize";
import { injectable } from "tsyringe";
import { Location } from "../models/location";
@injectable()
export class LocationRepository {
public async findAll() {
return await Location.findAll();
public async findAll(options?: FindOptions<Attributes<Location>>) {
return await Location.findAll(options);
}
public async findById(id: string) {

View File

@ -1,4 +1,5 @@
import { inject, injectable } from "tsyringe";
import { Op } from 'sequelize';
import { Location } from "../models/location";
import { LocationRepository } from "../repositories/locationRepository";
import { WifiLocationService } from "./wifiLocationService";
@ -8,6 +9,7 @@ interface CreateLocationParams {
wifi?: Coordinates;
gnss?: Coordinates;
ttn_gw?: Coordinates;
gnss_timestamp?: Date;
}
interface CreateLocationTriangulationParams {
@ -18,6 +20,7 @@ interface CreateLocationTriangulationParams {
}[];
ttn_gw: LocationSignal[];
gnss?: Coordinates;
gnss_timestamp?: Date;
}
interface LocationSignal extends Coordinates {
@ -52,6 +55,15 @@ export class LocationService {
return this.repository.findAll();
}
public async getAllGnssLocations() {
return this.repository.findAll({
where: {
gnss_latitude: { [Op.ne]: null },
gnss_longitude: { [Op.ne]: null }
}
});
}
public async getLocationById(id: string) {
return this.repository.findById(id);
}
@ -65,6 +77,7 @@ export class LocationService {
ttn_gw_longitude: data.ttn_gw?.longitude,
gnss_latitude: data.gnss?.latitude,
gnss_longitude: data.gnss?.longitude,
gnss_location_at_utc: data.gnss_timestamp,
});
}
@ -81,6 +94,7 @@ export class LocationService {
wifi: wifi_location,
ttn_gw: gateway_location,
gnss: data.gnss,
gnss_timestamp: data.gnss_timestamp,
});
}

View File

@ -0,0 +1,70 @@
import {injectable } from "tsyringe";
import { Counter, Gauge, collectDefaultMetrics} from "prom-client";
// Collect default system metrics (e.g., CPU, memory usage)
const prefix = 'locationhub_';
collectDefaultMetrics({ prefix });
@injectable()
export class MetricsService {
constructor(
) { }
public getRequestCounter() {
const requestCounter = new Counter({
name: `${prefix}http_requests_total`,
help: "Total number of HTTP requests",
labelNames: ["method", "route", "status"],
});
return requestCounter;
}
public getLocationsTotal() {
const locationsTotal = new Gauge({
name: `${prefix}locations_total`,
help: "Total number of location entries in database",
labelNames: ["database"],
});
return locationsTotal;
}
public getGnssLocationsTotal() {
const gnssLocationsTotal = new Gauge({
name: `${prefix}gnss_locations_total`,
help: "Total number of location entries with GNSS in database",
labelNames: ["database"],
});
return gnssLocationsTotal;
}
public getWifiLocationTotal() {
const wifiLocationTotal = new Gauge({
name: `${prefix}wifi_locations_total`,
help: "Total number of wifi location entries in database",
labelNames: ["database"],
});
return wifiLocationTotal;
}
public getWifiLocationResolvable() {
const wifiLocationNotResolvable = new Gauge({
name: `${prefix}wifi_locations_not_resolvable`,
help: "Unresolved number of wifi location entries in database",
labelNames: ["database"],
});
return wifiLocationNotResolvable;
}
public getLWifiLocationRequestLimitExceeded() {
const wifiLocationRequestLimitExceeded = new Gauge({
name: `${prefix}wifi_locations_request_limit_exceeded`,
help: "Unresolved number of wifi location because request limit exceeded entries in database",
labelNames: ["database"],
});
return wifiLocationRequestLimitExceeded;
}
public getTnnGatewayReceptions() {
const ttnGatewayReceptions = new Gauge({
name: `${prefix}ttn_gateway_receptions`,
help: "Total number of TTN Gateway receptions entries in database",
labelNames: ["database"],
});
return ttnGatewayReceptions;
}
}

View File

@ -29,6 +29,18 @@ export class WifiLocationService {
});
}
public async getAllWifiLocationsByNotResolvable() {
return this.repository.findAll({
where: { location_not_resolvable: true },
});
}
public async getAllWifiLocationsByRequestLimitExceeded() {
return this.repository.findAll({
where: { request_limit_exceeded: true },
});
}
public async getWifiLocationByMac(mac: string) {
let wifiLocation = await this.repository.findById(mac);

View File

@ -5,12 +5,14 @@ interface CreateWifiScanParams {
lp_ttn_end_device_uplinks_id: string;
mac: string;
rssi: number;
scanned_at_utc?: Date;
}
interface UpdateWifiScanParams {
wifi_scan_id: string;
mac?: string;
rssi?: number;
scanned_at_utc?: Date;
}
@injectable()