feat: tracker location based on multiple location providers #5

Merged
localhorst merged 19 commits from feature/ttn-location-algo into main 2025-01-02 14:57:34 +01:00
9 changed files with 170 additions and 7 deletions

View File

@ -10,7 +10,7 @@ TODO
### Database ### Database
**Change name of database and credentials as you like!** **Change name of database and credentials as you like!**
- Create new database: `CREATE DATABASE locationhub;` - Create new database: `CREATE DATABASE dev_locationhub;`
- Create new user for database: `GRANT ALL PRIVILEGES ON dev_locationhub.* TO 'dbuser'@'localhost' IDENTIFIED BY '1234';` - Create new user for database: `GRANT ALL PRIVILEGES ON dev_locationhub.* TO 'dbuser'@'localhost' IDENTIFIED BY '1234';`
- Import tables: `/usr/bin/mariadb -u dbuser -p1234 dev_locationhub < server/sql/tables.sql` - Import tables: `/usr/bin/mariadb -u dbuser -p1234 dev_locationhub < server/sql/tables.sql`

View File

@ -7,6 +7,6 @@ DB_PORT=""
WIGLE_TOKEN="" # Go to account and generate token "Encoded for use" WIGLE_TOKEN="" # Go to account and generate token "Encoded for use"
WIGLE_BASE_URL="https://api.wigle.net" WIGLE_BASE_URL="https://api.wigle.net"
WIGLE_NETWORK_SEARCH="/api/v2/network/search" WIGLE_NETWORK_SEARCH="/api/v2/network/search"
GET_LOCATION_WIFI_MAX_AGE=1209600000 # 14 Tage in Millisekunden (14 * 24 * 60 * 60 * 1000) GET_LOCATION_WIFI_MAX_AGE=1209600000 # 14 days in milliseconds (14 * 24 * 60 * 60 * 1000)
GET_LOCATION_WIFI_MAX=10000 GET_LOCATION_WIFI_MAX=10000
GET_LOCATION_WIFI_PRIMITIVE=true GET_LOCATION_WIFI_PRIMITIVE=true

View File

@ -13,11 +13,11 @@
"license": "ISC", "license": "ISC",
"devDependencies": { "devDependencies": {
"@types/express": "^5.0.0", "@types/express": "^5.0.0",
"@types/memoizee": "^0.4.11",
"@types/node": "^22.10.2", "@types/node": "^22.10.2",
"nodemon": "^3.1.9", "nodemon": "^3.1.9",
"ts-node": "^10.9.2", "ts-node": "^10.9.2",
"typescript": "^5.7.2", "typescript": "^5.7.2"
"@types/memoizee": "^0.4.11"
}, },
"dependencies": { "dependencies": {
"cors": "^2.8.5", "cors": "^2.8.5",

View File

@ -46,6 +46,8 @@ CREATE TABLE IF NOT EXISTS location (
wifi_longitude DOUBLE, wifi_longitude DOUBLE,
gnss_latitude DOUBLE, gnss_latitude DOUBLE,
gnss_longitude DOUBLE, gnss_longitude DOUBLE,
ttn_gw_latitude DOUBLE,
ttn_gw_longitude DOUBLE,
created_at_utc TIMESTAMP DEFAULT CURRENT_TIMESTAMP, created_at_utc TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at_utc TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE 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) FOREIGN KEY (lp_ttn_end_device_uplinks_id) REFERENCES lp_ttn_end_device_uplinks(lp_ttn_end_device_uplinks_id)

View File

@ -28,7 +28,8 @@ router.post(
async (req: Request, res: Response) => { async (req: Request, res: Response) => {
try { try {
const message = req.body as TtnMessage; const message = req.body as TtnMessage;
const { lp_ttn_end_device_uplinks_id } = // Create uplink record
const { lp_ttn_end_device_uplinks_id, latitude, longitude } =
await lpTtnEndDeviceUplinksService.createUplink({ await lpTtnEndDeviceUplinksService.createUplink({
device_id: message.end_device_ids.device_id, device_id: message.end_device_ids.device_id,
application_ids: application_ids:
@ -78,6 +79,7 @@ router.post(
latitude: g.latitude, latitude: g.latitude,
longitude: g.longitude, longitude: g.longitude,
})), })),
gnssLocation: { latitude, longitude }
}; };
domainEventEmitter.emit(TtnMessageReceivedEventName, event); domainEventEmitter.emit(TtnMessageReceivedEventName, event);

View File

@ -11,4 +11,8 @@ export type TtnMessageReceivedEvent = {
longitude: number; longitude: number;
altitude: number; altitude: number;
}[]; }[];
gnssLocation: {
latitude?: number;
longitude?: number;
localhorst marked this conversation as resolved Outdated

Hier habe ich Mist erzählt statt:

latitude: number | undefined; longitude: number | undefined;

geht auch einfach

latitude?: number; longitude?: number;

Hier habe ich Mist erzählt statt: `latitude: number | undefined; longitude: number | undefined;` geht auch einfach `latitude?: number; longitude?: number;`
}
}; };

View File

@ -3,11 +3,156 @@ import {
TtnMessageReceivedEvent, TtnMessageReceivedEvent,
TtnMessageReceivedEventName, TtnMessageReceivedEventName,
} from "../event/ttnMessageReceivedEvent"; } from "../event/ttnMessageReceivedEvent";
import { container } from "tsyringe";
import { LocationService } from "../services/locationService";
import { WifiScanService } from "../services/wifiScanService";
import { getLocationForWifiMemoized } from "../proxy/wigle";
const locationService = container.resolve(LocationService);
const wifiScanService = container.resolve(WifiScanService);
const CalculateTtnGatewayLocation = (event: TtnMessageReceivedEvent) => {
// Get location based on TTN Gateways
const virtualLocation = {
latitude: undefined as number | undefined,
longitude: undefined as number | undefined,
};
if (!event.ttnGateways || event.ttnGateways.length === 0) {
localhorst marked this conversation as resolved Outdated

Das ! kann weg.

Das ! kann weg.

Bzw. kann alles weg, wird nicht verwendet

Bzw. kann alles weg, wird nicht verwendet
console.log("No TTN Gateway location received!")
} else {
let totalWeight = 0;
let weightedLatitude = 0;
localhorst marked this conversation as resolved Outdated

Ich würde nach dem console.log ein return machen. Dann sparst du dir die else

Ich würde nach dem console.log ein return machen. Dann sparst du dir die else

ne, dann würdest ja evtl. die Location von GNSS oder Wifi nicht "parsen"

ne, dann würdest ja evtl. die Location von GNSS oder Wifi nicht "parsen"

Ich hätte es so aufgebaut Vorschlag:
Für jede Berechnung eine eigene Funktion die die Werte ausrechnet und dann zurück gibt:

const CalculateTtngatewayLocation = (event: TtnMessageReceivedEvent) => {
//... do stuff
return {
gnss_latitude: virtualLocation.latitude,
gnss_longitude: virtualLocation.longitude,
};
};

In der "Hauptfunktion" dann prüfen ob die werte da sind und wenn ja die Funktion aufrufen und die Werte zuweisen:
var location: Partial<Location> = {};

`// Get location based on TTN Gateways`
`if (event.ttnGateways && event.ttnGateways.length > 0) {`
  `// Option 1`
  `const virtualLocation = CalculateTtngatewayLocation(event);`
  `location.ttn_gw_latitude = virtualLocation.gnss_latitude;`
  `location.ttn_gw_longitude = virtualLocation.gnss_latitude;`

  `// Option 2`
  `const virtualLocation = CalculateTtngatewayLocation(event);`
 `location = { ...location, ...virtualLocation };`

  `// Option 3 -> fancy :D`
  `location = { ...location, ...CalculateTtngatewayLocation(event) };`
  `}`

So werden drei if else zu drei if und man hat weniger Komplexität in einer Funktion.

Ich hätte es so aufgebaut Vorschlag: Für jede Berechnung eine eigene Funktion die die Werte ausrechnet und dann zurück gibt: `const CalculateTtngatewayLocation = (event: TtnMessageReceivedEvent) => {` `//... do stuff` `return {` `gnss_latitude: virtualLocation.latitude,` `gnss_longitude: virtualLocation.longitude,` `};` `};` In der "Hauptfunktion" dann prüfen ob die werte da sind und wenn ja die Funktion aufrufen und die Werte zuweisen: `var location: Partial<Location> = {};` `// Get location based on TTN Gateways` `if (event.ttnGateways && event.ttnGateways.length > 0) {` `// Option 1` `const virtualLocation = CalculateTtngatewayLocation(event);` `location.ttn_gw_latitude = virtualLocation.gnss_latitude;` `location.ttn_gw_longitude = virtualLocation.gnss_latitude;` `// Option 2` `const virtualLocation = CalculateTtngatewayLocation(event);` `location = { ...location, ...virtualLocation };` `// Option 3 -> fancy :D` `location = { ...location, ...CalculateTtngatewayLocation(event) };` `}` So werden drei if else zu drei if und man hat weniger Komplexität in einer Funktion.
let weightedLongitude = 0;
event.ttnGateways.forEach(gw => {
const weight = 1 / Math.abs(gw.rssi); // Higher RSSI (closer to 0) gives more weight
totalWeight += weight;
weightedLatitude += gw.latitude * weight;
weightedLongitude += gw.longitude * weight;
});
// Calculate the weighted average to get the virtual location
virtualLocation.latitude = weightedLatitude / totalWeight;
virtualLocation.longitude = weightedLongitude / totalWeight;
console.log("Tracker location based on TTN Gateway location:", virtualLocation);
}
return {
ttn_latitude: virtualLocation.latitude,
ttn_longitude: virtualLocation.longitude,
};
};
const CalculateWifiLocation = async (event: TtnMessageReceivedEvent) => {
// Get location based on WiFi Scans
const virtualLocation = {
latitude: undefined as number | undefined,
longitude: undefined as number | undefined,
};
if (!event.wifis || event.wifis.length === 0) {
console.log("No WiFi scans received!")
} else {
// Process Wi-Fi data to compute weighted location
const wifiScans = await Promise.all(
event.wifis.map(async (wifi) => {
// Create new WiFi Scan entry if wigle.net reported location
const apiResponse = await getLocationForWifiMemoized(wifi.mac);
return {
localhorst marked this conversation as resolved Outdated

Der Wifi eintrag wird nur die die Datenbank geschrieben, wenn es eine Location gibt, sollte dieser nicht auch ohne reingeschrieben werden mit latitude und longitude undefined? Ansonsten würde ich es in drei steps unterteilen: Api Call, Datenbank entry erstellen, totalWeight + weightedLatitude + weightedLongitude ausrechnen. Ich glaube das macht es übersichtlicher ist aber vermutlich Geschmackssache. Mein Vorschlag:

` const wifiScans = await Promise.all(
event.wifis.map(async (wifi) => {
const apiResponse = await getLocationForWifiMemoized(wifi.mac);

      return {
        lp_ttn_end_device_uplinks_id: event.lp_ttn_end_device_uplinks_id,
        mac: wifi.mac,
        rssi: wifi.rssi,
        latitude: apiResponse?.results[0]?.trilat,
        longitude: apiResponse?.results[0]?.trilong,
      };
    })
  );

  await wifiScanService.createWifiScans(wifiScans);

  const { totalWeight, weightedLatitude, weightedLongitude } =
    wifiScans.reduce(
      (acc, { latitude, longitude, rssi }) => {
        if (latitude && longitude && rssi !== 0) {
          const weight = 1 / Math.abs(rssi);

          acc.totalWeight += weight;
          acc.weightedLatitude += latitude * weight;
          acc.weightedLongitude += longitude * weight;
        }

        return acc;
      },
      {
        totalWeight: 0,
        weightedLatitude: 0,
        weightedLongitude: 0,
      }
    );

  const virtualLocation = {
    latitude: weightedLatitude / totalWeight,
    longitude: weightedLongitude / totalWeight,
  };`
Der Wifi eintrag wird nur die die Datenbank geschrieben, wenn es eine Location gibt, sollte dieser nicht auch ohne reingeschrieben werden mit latitude und longitude undefined? Ansonsten würde ich es in drei steps unterteilen: Api Call, Datenbank entry erstellen, totalWeight + weightedLatitude + weightedLongitude ausrechnen. Ich glaube das macht es übersichtlicher ist aber vermutlich Geschmackssache. Mein Vorschlag: ` const wifiScans = await Promise.all( event.wifis.map(async (wifi) => { const apiResponse = await getLocationForWifiMemoized(wifi.mac); return { lp_ttn_end_device_uplinks_id: event.lp_ttn_end_device_uplinks_id, mac: wifi.mac, rssi: wifi.rssi, latitude: apiResponse?.results[0]?.trilat, longitude: apiResponse?.results[0]?.trilong, }; }) ); await wifiScanService.createWifiScans(wifiScans); const { totalWeight, weightedLatitude, weightedLongitude } = wifiScans.reduce( (acc, { latitude, longitude, rssi }) => { if (latitude && longitude && rssi !== 0) { const weight = 1 / Math.abs(rssi); acc.totalWeight += weight; acc.weightedLatitude += latitude * weight; acc.weightedLongitude += longitude * weight; } return acc; }, { totalWeight: 0, weightedLatitude: 0, weightedLongitude: 0, } ); const virtualLocation = { latitude: weightedLatitude / totalWeight, longitude: weightedLongitude / totalWeight, };`
lp_ttn_end_device_uplinks_id: event.lp_ttn_end_device_uplinks_id,
mac: wifi.mac,
rssi: wifi.rssi,
latitude: apiResponse?.results[0]?.trilat,
longitude: apiResponse?.results[0]?.trilong,
localhorst marked this conversation as resolved Outdated

await wifiScanService.createWifiScan

await wifiScanService.createWifiScan
};
})
);
await wifiScanService.createWifiScans(wifiScans);
const { totalWeight, weightedLatitude, weightedLongitude } =
wifiScans.reduce(
(acc, { latitude, longitude, rssi }) => {
localhorst marked this conversation as resolved Outdated

await kann weg

await kann weg
if (latitude && longitude && rssi !== 0) {
const weight = 1 / Math.abs(rssi);
acc.totalWeight += weight;
acc.weightedLatitude += latitude * weight;
localhorst marked this conversation as resolved Outdated

await kann weg

await kann weg
acc.weightedLongitude += longitude * weight;
}
return acc;
},
{
totalWeight: 0,
weightedLatitude: 0,
weightedLongitude: 0,
}
);
// Calculate the weighted average to get the virtual location
virtualLocation.latitude = weightedLatitude / totalWeight;
virtualLocation.longitude = weightedLongitude / totalWeight;
console.log("Tracker location based on WiFi Scan location:", virtualLocation);
}
return {
wifi_latitude: virtualLocation.latitude,
wifi_longitude: virtualLocation.longitude,
};
};
const CalculateGnssLocation = (event: TtnMessageReceivedEvent) => {
// Get location based on reported GNSS
if (event.gnssLocation.latitude === undefined || event.gnssLocation.longitude === undefined) {
console.log("No valid GNSS location received!");
}
return {
gnss_latitude: event.gnssLocation.latitude,
gnss_longitude: event.gnssLocation.longitude,
};
};
domainEventEmitter.on( domainEventEmitter.on(
TtnMessageReceivedEventName, TtnMessageReceivedEventName,
async (event: TtnMessageReceivedEvent) => { async (event: TtnMessageReceivedEvent) => {
console.log(event); console.log(event);
// TODO Hendrik 🚀
var wifi_based_latitude: number | undefined = undefined;
var wifi_based_longitude: number | undefined = undefined;
var gnss_based_latitude: number | undefined = undefined;
var gnss_based_longitude: number | undefined = undefined;
var ttn_gw_based_latitude: number | undefined = undefined;
var ttn_gw_based_longitude: number | undefined = undefined;
if (event.ttnGateways && event.ttnGateways.length > 0) {
const virtualLocation = CalculateTtnGatewayLocation(event);
ttn_gw_based_latitude = virtualLocation.ttn_latitude;
ttn_gw_based_longitude = virtualLocation.ttn_longitude;
}
if (event.wifis && event.wifis.length > 0) {
const virtualLocation = await CalculateWifiLocation(event);
wifi_based_latitude = virtualLocation.wifi_latitude;
wifi_based_longitude = virtualLocation.wifi_longitude;
}
const virtualLocation = CalculateGnssLocation(event);
gnss_based_latitude = virtualLocation.gnss_latitude;
gnss_based_longitude = virtualLocation.gnss_longitude;
const newLocation = await locationService.createLocation({
lp_ttn_end_device_uplinks_id: event.lp_ttn_end_device_uplinks_id,
ttn_gw_latitude: ttn_gw_based_latitude,
ttn_gw_longitude: ttn_gw_based_longitude,
gnss_latitude: gnss_based_latitude,
gnss_longitude: gnss_based_longitude,
wifi_latitude: wifi_based_latitude,
wifi_longitude: wifi_based_longitude,
});
console.log(newLocation)
} }
); );

View File

@ -25,5 +25,5 @@ app.use("/api/locations", locationRoutes);
app.use("/api/ttn", ttnRoutes); app.use("/api/ttn", ttnRoutes);
app.listen(PORT, () => { app.listen(PORT, () => {
console.log(`🚀 Server läuft auf http://localhost:${PORT}`); console.log(`🚀 Server runs here: http://localhost:${PORT}`);
}); });

View File

@ -8,6 +8,8 @@ export class Location extends Model {
public wifi_longitude!: number; public wifi_longitude!: number;
public gnss_latitude!: number; public gnss_latitude!: number;
public gnss_longitude!: number; public gnss_longitude!: number;
public ttn_gw_latitude!: number;
public ttn_gw_longitude!: number;
public created_at_utc!: Date; public created_at_utc!: Date;
public updated_at_utc!: Date; public updated_at_utc!: Date;
} }
@ -40,6 +42,14 @@ Location.init(
type: DataTypes.NUMBER, type: DataTypes.NUMBER,
allowNull: true, allowNull: true,
}, },
ttn_gw_latitude: {
type: DataTypes.NUMBER,
allowNull: true,
},
ttn_gw_longitude: {
type: DataTypes.NUMBER,
allowNull: true,
},
created_at_utc: { created_at_utc: {
type: DataTypes.DATE, type: DataTypes.DATE,
defaultValue: DataTypes.NOW, defaultValue: DataTypes.NOW,