@lifefinder/vsm-mqtt-client-open-source 0.0.46
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE.txt +21 -0
- package/README.md +210 -0
- package/chirpstack-devices.list +4 -0
- package/constants.json +14 -0
- package/decorators/default.js +10 -0
- package/decorators/minimal.js +19 -0
- package/decorators/yggio-push.js +11 -0
- package/helium-devices.list +4 -0
- package/integrations/chirpstack3.js +135 -0
- package/integrations/chirpstack4.js +120 -0
- package/integrations/helium.js +150 -0
- package/integrations/yggio.js +136 -0
- package/package.json +19 -0
- package/publishers/console.js +13 -0
- package/publishers/https.js +38 -0
- package/publishers/mqtt.js +53 -0
- package/rules.js +246 -0
- package/solvers/aws.js +102 -0
- package/solvers/combain-loracloud.js +263 -0
- package/solvers/loracloud.js +287 -0
- package/solvers/none.js +40 -0
- package/store.js +82 -0
- package/util.js +35 -0
- package/vsm-mqtt-client.js +299 -0
package/solvers/aws.js
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
|
|
2
|
+
|
|
3
|
+
let iotwireless;
|
|
4
|
+
const solvePosition = async (args, data) => {
|
|
5
|
+
|
|
6
|
+
const isWifi = data.semtechEncoded && (data.semtechEncoded.msgtype === "wifi");
|
|
7
|
+
|
|
8
|
+
let body = isWifi ? data.wifi : (data.semtechEncoded ? data.semtechEncoded : data.semtechGpsEncoded);
|
|
9
|
+
body = JSON.parse(JSON.stringify(body)); // Make a copy of this object since it is manipulated below
|
|
10
|
+
if (isWifi) {
|
|
11
|
+
if ((!body.wifiAccessPoints) || body.wifiAccessPoints.length < 2) {
|
|
12
|
+
console.log(data);
|
|
13
|
+
throw new Error("Not enough access points to resolve wifi position")
|
|
14
|
+
}
|
|
15
|
+
body.wifiAccessPoints.forEach((accessPoint) => {
|
|
16
|
+
accessPoint.Rss = accessPoint.signalStrength;
|
|
17
|
+
delete accessPoint.signalStrength;
|
|
18
|
+
accessPoint.MacAddress = accessPoint.macAddress
|
|
19
|
+
delete accessPoint.macAddress;
|
|
20
|
+
});
|
|
21
|
+
const params = {
|
|
22
|
+
WiFiAccessPoints: body.wifiAccessPoints,
|
|
23
|
+
}
|
|
24
|
+
console.log ('Sending request to AWS to resolve position');
|
|
25
|
+
console.log('Params: ', params);
|
|
26
|
+
return await iotwireless.getPositionEstimate(params, function(err, response) {
|
|
27
|
+
if (err) {
|
|
28
|
+
console.log('Something went wrong when calling "getPositionEstimate" for the AWS WiFi solver', err, err.stack);
|
|
29
|
+
} else {
|
|
30
|
+
const buf = Buffer.from(response.GeoJsonPayload);
|
|
31
|
+
const decodedString = buf.toString();
|
|
32
|
+
const decodedResponse = JSON.parse(decodedString);
|
|
33
|
+
args.v && console.log("AWS WiFi solver response:", decodedResponse);
|
|
34
|
+
return {
|
|
35
|
+
latitude: decodedResponse?.coordinates[1],
|
|
36
|
+
longitude: decodedResponse?.coordinates[0],
|
|
37
|
+
verticalAccuracy: decodedResponse?.properties?.verticalAccuracy,
|
|
38
|
+
verticalConfidenceLevel: decodedResponse?.properties?.verticalConfidenceLevel,
|
|
39
|
+
accuracy: decodedResponse?.properties?.horizontalAccuracy,
|
|
40
|
+
horizontalConfidenceLevel: decodedResponse?.properties?.horizontalConfidenceLevel,
|
|
41
|
+
positionTimestamp: decodedResponse?.properties?.timestamp,
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
});
|
|
45
|
+
} else {
|
|
46
|
+
if (!body.msgtype === 'gnss' || !body?.gnss_capture_time || !body.payload) {
|
|
47
|
+
console.log('Error, data:', body);
|
|
48
|
+
throw new Error("Not enough information to resolve Gnss position");
|
|
49
|
+
} else {
|
|
50
|
+
const params = {
|
|
51
|
+
"Gnss": {
|
|
52
|
+
"CaptureTime": body?.gnss_capture_time,
|
|
53
|
+
"Payload": body?.payload,
|
|
54
|
+
}
|
|
55
|
+
};
|
|
56
|
+
return await iotwireless.getPositionEstimate(params, function(err, response) {
|
|
57
|
+
if (err) {
|
|
58
|
+
console.log('Something went wrong when calling "getPositionEstimate" for the AWS Gnss solver', err, err.stack);
|
|
59
|
+
} else {
|
|
60
|
+
const buf = Buffer.from(response.GeoJsonPayload);
|
|
61
|
+
const decodedString = buf.toString();
|
|
62
|
+
const decodedResponse = JSON.parse(decodedString);
|
|
63
|
+
args.v && console.log("AWS Gnss solver response:", decodedResponse);
|
|
64
|
+
return {
|
|
65
|
+
latitude: decodedResponse?.coordinates[0],
|
|
66
|
+
longitude: decodedResponse?.coordinates[1],
|
|
67
|
+
altitude: decodedResponse?.coordinates[2],
|
|
68
|
+
verticalAccuracy: decodedResponse?.properties?.verticalAccuracy,
|
|
69
|
+
verticalConfidenceLevel: decodedResponse?.properties?.verticalConfidenceLevel,
|
|
70
|
+
accuracy: decodedResponse?.properties?.horizontalAccuracy,
|
|
71
|
+
horizontalConfidenceLevel: decodedResponse?.properties?.horizontalConfidenceLevel,
|
|
72
|
+
positionTimestamp: decodedResponse?.properties?.timestamp,
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
module.exports.api = {
|
|
81
|
+
solvePosition,
|
|
82
|
+
loadAlmanac : async (args) => { return undefined; },
|
|
83
|
+
checkArgumentsOrExit: (args)=>{if (args.z !== 'aws') throw new Error("Flag -z <aws> is required for AWS solver."); },
|
|
84
|
+
getVersionString: ()=>"AWS Solver",
|
|
85
|
+
initialize: (args) => {
|
|
86
|
+
const CONSTANTS = require('../constants');
|
|
87
|
+
const AWS = require('aws-sdk');
|
|
88
|
+
AWS.config.apiVersions = {
|
|
89
|
+
iotwireless: CONSTANTS.AWS.VERSION,
|
|
90
|
+
// other service API versions
|
|
91
|
+
};
|
|
92
|
+
// Set the region and user credentials
|
|
93
|
+
const config = {
|
|
94
|
+
accessKeyId: CONSTANTS.AWS.ACCESS_KEY_ID,
|
|
95
|
+
secretAccessKey: CONSTANTS.AWS.SECRET_ACCESS_KEY,
|
|
96
|
+
region: CONSTANTS.AWS.REGION,
|
|
97
|
+
// other service API versions
|
|
98
|
+
}
|
|
99
|
+
// Create the service object (IotWireless- Service)
|
|
100
|
+
iotwireless = new AWS.IoTWireless(config);
|
|
101
|
+
}
|
|
102
|
+
};
|
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
const combainLoraCloud= "https://lw.traxmate.io";
|
|
2
|
+
const endpointWifi = combainLoraCloud +"/api/v1/solve/loraWifi";
|
|
3
|
+
const endpointGnss = combainLoraCloud +"/api/v1/solve/gnss_lora_edge_singleframe";
|
|
4
|
+
const endpointAlmanac = combainLoraCloud + "/api/v1/almanac/full";
|
|
5
|
+
|
|
6
|
+
// import fetch from 'node-fetch'
|
|
7
|
+
const fetch = (...args) => import('node-fetch').then(({default: fetch}) => fetch(...args));
|
|
8
|
+
|
|
9
|
+
const solvePosition = async (args, data) => {
|
|
10
|
+
if (!args.k)
|
|
11
|
+
return;
|
|
12
|
+
|
|
13
|
+
const isWifi = data.semtechEncoded && (data.semtechEncoded.msgtype === "wifi");
|
|
14
|
+
const endpoint = isWifi ? endpointWifi : endpointGnss;
|
|
15
|
+
let body = isWifi ? data.wifi : (data.semtechEncoded ? data.semtechEncoded : data.semtechGpsEncoded);
|
|
16
|
+
body = JSON.parse(JSON.stringify(body)); // Make a copy of this object since it is manipulated below
|
|
17
|
+
if (isWifi) {
|
|
18
|
+
delete body.timestamp;
|
|
19
|
+
if ((!body.wifiAccessPoints) || body.wifiAccessPoints.length < 2) {
|
|
20
|
+
console.log(data);
|
|
21
|
+
return {errors:["Too few access points to solve position ("+ (body.wifiAccessPoints ? body.wifiAccessPoints.length:"none" )+")"]};
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// The below is a workaround since this data is likely not available in good enough precision, in particular with mobile gateways
|
|
25
|
+
body.lorawan = [
|
|
26
|
+
{
|
|
27
|
+
"gatewayId": "00-00-E4-77-6B-00-1A-5D",
|
|
28
|
+
"antennaId": 0,
|
|
29
|
+
"rssi": -86.0,
|
|
30
|
+
"snr": 15.0,
|
|
31
|
+
"toa": 10000,
|
|
32
|
+
"antennaLocation": {
|
|
33
|
+
"latitude":46.98886,
|
|
34
|
+
"longitude":6.91287,
|
|
35
|
+
"altitude":513
|
|
36
|
+
}
|
|
37
|
+
},
|
|
38
|
+
{
|
|
39
|
+
"gatewayId": "00-00-E4-77-6B-00-1A-5D",
|
|
40
|
+
"antennaId": 1,
|
|
41
|
+
"rssi": -87.0,
|
|
42
|
+
"snr": 15.0,
|
|
43
|
+
"toa": 5000,
|
|
44
|
+
"antennaLocation": {
|
|
45
|
+
"latitude":46.98886,
|
|
46
|
+
"longitude":6.91287,
|
|
47
|
+
"altitude":513
|
|
48
|
+
}
|
|
49
|
+
},
|
|
50
|
+
{
|
|
51
|
+
"gatewayId": "00-00-E4-77-6B-00-1A-97",
|
|
52
|
+
"antennaId": 0,
|
|
53
|
+
"rssi": -89.0,
|
|
54
|
+
"snr": 15.0,
|
|
55
|
+
"toa": 8000,
|
|
56
|
+
"antennaLocation": {
|
|
57
|
+
"latitude":46.983753,
|
|
58
|
+
"longitude":6.906008,
|
|
59
|
+
"altitude":479
|
|
60
|
+
}
|
|
61
|
+
},
|
|
62
|
+
{
|
|
63
|
+
"gatewayId": "00-00-E4-77-6B-00-1A-97",
|
|
64
|
+
"antennaId": 1,
|
|
65
|
+
"rssi": -89.0,
|
|
66
|
+
"snr": 10.0,
|
|
67
|
+
"toa": 20000,
|
|
68
|
+
"antennaLocation": {
|
|
69
|
+
"latitude":46.983753,
|
|
70
|
+
"longitude":6.906008,
|
|
71
|
+
"altitude":479
|
|
72
|
+
}
|
|
73
|
+
}];
|
|
74
|
+
}
|
|
75
|
+
// console.log(endpoint);
|
|
76
|
+
delete body.msgtype;
|
|
77
|
+
delete body.timestamp;
|
|
78
|
+
// console.log(body);
|
|
79
|
+
const response = await fetch(endpoint,
|
|
80
|
+
{
|
|
81
|
+
method:"POST",
|
|
82
|
+
headers: {
|
|
83
|
+
"Authorization": args.k,
|
|
84
|
+
"Content-type" : "application/json",
|
|
85
|
+
},
|
|
86
|
+
body:JSON.stringify(body),
|
|
87
|
+
})
|
|
88
|
+
.then(response => {
|
|
89
|
+
if(!response.ok) {
|
|
90
|
+
console.log(response.statusText);
|
|
91
|
+
throw new Error('Could not fetch ' + (isWifi ? "wifi":"gnss")+ ' api, response: ' + response.status);
|
|
92
|
+
}
|
|
93
|
+
return response.json();
|
|
94
|
+
})
|
|
95
|
+
.then(data => data)
|
|
96
|
+
.catch(err => {
|
|
97
|
+
console.log("API failed: " + endpoint + " " + err.message);
|
|
98
|
+
return null;
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
args.v && console.log("Combain loraCloud solver response:", response);
|
|
102
|
+
if (isWifi) {
|
|
103
|
+
if (response && response.result && response.result.algorithmType !== "Wifi")
|
|
104
|
+
return {errors:["Got wrong type of response: " + response.result.algorithmType]};
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// A small cludge or two - but positions are not particularily interresting unless they
|
|
108
|
+
// are timestamped. The resolver can give timestamps, but this is added as an insurance
|
|
109
|
+
if (response && response.result && response.result.latitude)
|
|
110
|
+
response.result.positionTimestamp = new Date();
|
|
111
|
+
return response;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// Reverse run-length encode, encodes into a buffer which ends with a byte
|
|
115
|
+
// of format either of
|
|
116
|
+
// (0xxx xxxx) is replaced with x bytes of zeros (x>=1)
|
|
117
|
+
// (1xxx xxxx) meaning it should copy xx bytes. (x>=1)
|
|
118
|
+
// Next previous byte is of same format.
|
|
119
|
+
// Use this function with a following RRLEDecode for checking that there is no buffer underrun
|
|
120
|
+
// before sending the data.
|
|
121
|
+
// TODO: Optimize to avoid excessive copying of data.
|
|
122
|
+
const RRLEEncode = (buf) => {
|
|
123
|
+
let output = Buffer.from('', 'hex');
|
|
124
|
+
let pos = 0;
|
|
125
|
+
let len = buf.length;
|
|
126
|
+
let nonzeros = 0;
|
|
127
|
+
while (pos < len) {
|
|
128
|
+
// Count how many zeros are at the current position
|
|
129
|
+
let zeros;
|
|
130
|
+
for (zeros = 0; zeros < len-pos && zeros < 128; ++zeros) {
|
|
131
|
+
if (buf[pos+zeros] !== 0)
|
|
132
|
+
break;
|
|
133
|
+
}
|
|
134
|
+
if (zeros >= 2) {
|
|
135
|
+
// Output how many non-zeros we just cooked
|
|
136
|
+
if (nonzeros > 0) {
|
|
137
|
+
// add an overhead byte
|
|
138
|
+
let buffer2 = Buffer.from([0x80 + nonzeros]);
|
|
139
|
+
output = Buffer.concat([output, buffer2]);
|
|
140
|
+
}
|
|
141
|
+
// output code 0xxx xxxx
|
|
142
|
+
let buffer2 = Buffer.from([zeros]);
|
|
143
|
+
output = Buffer.concat([output, buffer2]);
|
|
144
|
+
pos += (zeros-1);
|
|
145
|
+
nonzeros = 0;
|
|
146
|
+
}
|
|
147
|
+
else { // Less than two zeroes here
|
|
148
|
+
// Copy this positions value to output
|
|
149
|
+
nonzeros++;
|
|
150
|
+
let buffer2 = Buffer.from([buf[pos]]);
|
|
151
|
+
output = Buffer.concat([output, buffer2]);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
if (nonzeros == 127 || pos === len-1) {
|
|
155
|
+
if (nonzeros > 0) {
|
|
156
|
+
// Need to dump one extra byte stating how many non-zeros
|
|
157
|
+
let buffer2 = Buffer.from([0x80 + nonzeros]);
|
|
158
|
+
output = Buffer.concat([output, buffer2]);
|
|
159
|
+
nonzeros = 0;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
pos++;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
console.log("RRLE Result: " + output.length + " bytes, original: " + buf.length + " bytes");
|
|
167
|
+
return output;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
const RRLEDecode = (buf, expectedSize) => {
|
|
171
|
+
let inpos = buf.length;
|
|
172
|
+
let result = Buffer.alloc(expectedSize, 0);
|
|
173
|
+
let outpos = expectedSize;
|
|
174
|
+
while (inpos > 0) {
|
|
175
|
+
if (outpos <= 0)
|
|
176
|
+
throw new Error(`Negative output position`);
|
|
177
|
+
inpos--;
|
|
178
|
+
if (outpos < inpos)
|
|
179
|
+
throw new Error(`Output position before input position: outpos: ${outpos} inpos: ${inpos}`);
|
|
180
|
+
let len = buf[inpos] & 0x7f;
|
|
181
|
+
let zero = (buf[inpos] & 0x80) === 0;
|
|
182
|
+
|
|
183
|
+
if (len > outpos)
|
|
184
|
+
throw new Error(`Invalid length found at inpos ${inpos}, would cause output underrun`);
|
|
185
|
+
|
|
186
|
+
if (!zero) {
|
|
187
|
+
while (len--)
|
|
188
|
+
result[--outpos] = buf[--inpos];
|
|
189
|
+
} else {
|
|
190
|
+
// Skip zeros
|
|
191
|
+
outpos-=len;
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
if (outpos != 0 || inpos != 0)
|
|
195
|
+
throw new Error("Output and input did not end at zero");
|
|
196
|
+
return result;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
let almanacCache;
|
|
200
|
+
let almanacTimestamp_ms;
|
|
201
|
+
|
|
202
|
+
const ALMANAC_CACHE_MAX_AGE_S = 60*60*24;
|
|
203
|
+
|
|
204
|
+
const loadAlmanac = async (args) => {
|
|
205
|
+
const nowMs = new Date().getTime();
|
|
206
|
+
if (almanacCache && almanacTimestamp_ms && nowMs-almanacTimestamp_ms < ALMANAC_CACHE_MAX_AGE_S*1000)
|
|
207
|
+
return almanacCache;
|
|
208
|
+
|
|
209
|
+
if (!args.k)
|
|
210
|
+
return;
|
|
211
|
+
console.log(endpointAlmanac);
|
|
212
|
+
const response = await fetch(endpointAlmanac,
|
|
213
|
+
{
|
|
214
|
+
method:"GET",
|
|
215
|
+
headers: {
|
|
216
|
+
// "Ocp-Apim-Subscription-Key": loraoldcloudapikey,
|
|
217
|
+
"Authorization" : args.k,
|
|
218
|
+
"Content-type" : "application/json"
|
|
219
|
+
},
|
|
220
|
+
})
|
|
221
|
+
.then(response => { if(!response.ok) throw new Error('Login failed - check username and password'); return response.json()})
|
|
222
|
+
.then(data => data)
|
|
223
|
+
.catch(err => {
|
|
224
|
+
console.log("Login failed - connection problem?");
|
|
225
|
+
return null;
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
if (response && response.result && response.result.almanac_image) {
|
|
229
|
+
const buf = Buffer.from(response.result.almanac_image, "base64");
|
|
230
|
+
response.result.almanac_image = buf.toString("hex");
|
|
231
|
+
|
|
232
|
+
// Optionally add a compressed and tested version of the same image to the result
|
|
233
|
+
try {
|
|
234
|
+
const compressed = RRLEEncode(buf);
|
|
235
|
+
if (compressed.length >= buf.length)
|
|
236
|
+
throw new Error("The compressed length exceeds the uncompressed length");
|
|
237
|
+
const decompressed = RRLEDecode(compressed, buf.length);
|
|
238
|
+
if (decompressed.length != buf.length)
|
|
239
|
+
throw new Error("The uncompress yields wrong length");
|
|
240
|
+
const compressed_hex = decompressed.toString('hex');
|
|
241
|
+
if (compressed_hex !== response.result.almanac_image)
|
|
242
|
+
throw new Error("Decompression generated wrong result");
|
|
243
|
+
response.result.almanac_compressed = compressed.toString('hex');
|
|
244
|
+
} catch (err) {
|
|
245
|
+
console.log(err);
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
if (response && response.result && response.result.almanac_image) {
|
|
250
|
+
console.log("ALMANAC ADDED TO CACHE:", response);
|
|
251
|
+
almanacTimestamp_ms = nowMs;
|
|
252
|
+
almanacCache = response;
|
|
253
|
+
}
|
|
254
|
+
return response;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
module.exports.api = {
|
|
258
|
+
solvePosition,
|
|
259
|
+
loadAlmanac,
|
|
260
|
+
checkArgumentsOrExit: (args)=>{if (!args.k) throw new Error("-k <API key> is required for combainLoraCloud solver"); },
|
|
261
|
+
getVersionString: ()=>"combainLoraCloud Solver",
|
|
262
|
+
initialize: (args) => {}
|
|
263
|
+
};
|
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
/*
|
|
2
|
+
The MIT License (MIT)
|
|
3
|
+
|
|
4
|
+
Copyright Sensative AB 2023. All rights reserved.
|
|
5
|
+
|
|
6
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
7
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
8
|
+
in the Software without restriction, including without limitation the rights
|
|
9
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
10
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
11
|
+
furnished to do so, subject to the following conditions:
|
|
12
|
+
|
|
13
|
+
The above copyright notice and this permission notice shall be included in all
|
|
14
|
+
copies or substantial portions of the Software.
|
|
15
|
+
|
|
16
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
17
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
18
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
19
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
20
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
21
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
22
|
+
SOFTWARE.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
const loracloud="https://mgs.loracloud.com";
|
|
26
|
+
const endpointWifi = loracloud+"/api/v1/solve/loraWifi";
|
|
27
|
+
const endpointGnss = loracloud+"/api/v1/solve/gnss_lr1110_singleframe";
|
|
28
|
+
const endpointAlmanac = loracloud + "/api/v1/almanac/full";
|
|
29
|
+
|
|
30
|
+
// import fetch from 'node-fetch'
|
|
31
|
+
const fetch = (...args) => import('node-fetch').then(({default: fetch}) => fetch(...args));
|
|
32
|
+
|
|
33
|
+
const solvePosition = async (args, data) => {
|
|
34
|
+
if (!args.k)
|
|
35
|
+
return;
|
|
36
|
+
|
|
37
|
+
const isWifi = data.semtechEncoded && (data.semtechEncoded.msgtype === "wifi");
|
|
38
|
+
const endpoint = isWifi ? endpointWifi : endpointGnss;
|
|
39
|
+
let body = isWifi ? data.wifi : (data.semtechEncoded ? data.semtechEncoded : data.semtechGpsEncoded);
|
|
40
|
+
body = JSON.parse(JSON.stringify(body)); // Make a copy of this object since it is manipulated below
|
|
41
|
+
if (isWifi) {
|
|
42
|
+
delete body.timestamp;
|
|
43
|
+
if ((!body.wifiAccessPoints) || body.wifiAccessPoints.length < 2) {
|
|
44
|
+
console.log(data);
|
|
45
|
+
return {errors:["Too few access points to solve position ("+ (body.wifiAccessPoints ? body.wifiAccessPoints.length:"none" )+")"]};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// The below is a workaround since this data is likely not available in good enough precision, in particular with mobile gateways
|
|
49
|
+
body.lorawan = [
|
|
50
|
+
{
|
|
51
|
+
"gatewayId": "00-00-E4-77-6B-00-1A-5D",
|
|
52
|
+
"antennaId": 0,
|
|
53
|
+
"rssi": -86.0,
|
|
54
|
+
"snr": 15.0,
|
|
55
|
+
"toa": 10000,
|
|
56
|
+
"antennaLocation": {
|
|
57
|
+
"latitude":46.98886,
|
|
58
|
+
"longitude":6.91287,
|
|
59
|
+
"altitude":513
|
|
60
|
+
}
|
|
61
|
+
},
|
|
62
|
+
{
|
|
63
|
+
"gatewayId": "00-00-E4-77-6B-00-1A-5D",
|
|
64
|
+
"antennaId": 1,
|
|
65
|
+
"rssi": -87.0,
|
|
66
|
+
"snr": 15.0,
|
|
67
|
+
"toa": 5000,
|
|
68
|
+
"antennaLocation": {
|
|
69
|
+
"latitude":46.98886,
|
|
70
|
+
"longitude":6.91287,
|
|
71
|
+
"altitude":513
|
|
72
|
+
}
|
|
73
|
+
},
|
|
74
|
+
{
|
|
75
|
+
"gatewayId": "00-00-E4-77-6B-00-1A-97",
|
|
76
|
+
"antennaId": 0,
|
|
77
|
+
"rssi": -89.0,
|
|
78
|
+
"snr": 15.0,
|
|
79
|
+
"toa": 8000,
|
|
80
|
+
"antennaLocation": {
|
|
81
|
+
"latitude":46.983753,
|
|
82
|
+
"longitude":6.906008,
|
|
83
|
+
"altitude":479
|
|
84
|
+
}
|
|
85
|
+
},
|
|
86
|
+
{
|
|
87
|
+
"gatewayId": "00-00-E4-77-6B-00-1A-97",
|
|
88
|
+
"antennaId": 1,
|
|
89
|
+
"rssi": -89.0,
|
|
90
|
+
"snr": 10.0,
|
|
91
|
+
"toa": 20000,
|
|
92
|
+
"antennaLocation": {
|
|
93
|
+
"latitude":46.983753,
|
|
94
|
+
"longitude":6.906008,
|
|
95
|
+
"altitude":479
|
|
96
|
+
}
|
|
97
|
+
}];
|
|
98
|
+
}
|
|
99
|
+
// console.log(endpoint);
|
|
100
|
+
delete body.msgtype;
|
|
101
|
+
delete body.timestamp;
|
|
102
|
+
// console.log(body);
|
|
103
|
+
const response = await fetch(endpoint,
|
|
104
|
+
{
|
|
105
|
+
method:"POST",
|
|
106
|
+
headers: {
|
|
107
|
+
"Authorization": args.k,
|
|
108
|
+
"Content-type" : "application/json",
|
|
109
|
+
},
|
|
110
|
+
body:JSON.stringify(body),
|
|
111
|
+
})
|
|
112
|
+
.then(response => {
|
|
113
|
+
if(!response.ok) {
|
|
114
|
+
console.log(response.statusText);
|
|
115
|
+
throw new Error('Could not fetch ' + (isWifi ? "wifi":"gnss")+ ' api, response: ' + response.status);
|
|
116
|
+
}
|
|
117
|
+
return response.json();
|
|
118
|
+
})
|
|
119
|
+
.then(data => data)
|
|
120
|
+
.catch(err => {
|
|
121
|
+
console.log("API failed: " + endpoint + " " + err.message);
|
|
122
|
+
return null;
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
args.v && console.log("Semtech solver response:", response);
|
|
126
|
+
if (isWifi) {
|
|
127
|
+
if (response && response.result && response.result.algorithmType !== "Wifi")
|
|
128
|
+
return {errors:["Got wrong type of response: " + response.result.algorithmType]};
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// A small cludge or two - but positions are not particularily interresting unless they
|
|
132
|
+
// are timestamped. The resolver can give timestamps, but this is added as an insurance
|
|
133
|
+
if (response && response.result && response.result.latitude)
|
|
134
|
+
response.result.positionTimestamp = new Date();
|
|
135
|
+
return response;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// Reverse run-length encode, encodes into a buffer which ends with a byte
|
|
139
|
+
// of format either of
|
|
140
|
+
// (0xxx xxxx) is replaced with x bytes of zeros (x>=1)
|
|
141
|
+
// (1xxx xxxx) meaning it should copy xx bytes. (x>=1)
|
|
142
|
+
// Next previous byte is of same format.
|
|
143
|
+
// Use this function with a following RRLEDecode for checking that there is no buffer underrun
|
|
144
|
+
// before sending the data.
|
|
145
|
+
// TODO: Optimize to avoid excessive copying of data.
|
|
146
|
+
const RRLEEncode = (buf) => {
|
|
147
|
+
let output = Buffer.from('', 'hex');
|
|
148
|
+
let pos = 0;
|
|
149
|
+
let len = buf.length;
|
|
150
|
+
let nonzeros = 0;
|
|
151
|
+
while (pos < len) {
|
|
152
|
+
// Count how many zeros are at the current position
|
|
153
|
+
let zeros;
|
|
154
|
+
for (zeros = 0; zeros < len-pos && zeros < 128; ++zeros) {
|
|
155
|
+
if (buf[pos+zeros] !== 0)
|
|
156
|
+
break;
|
|
157
|
+
}
|
|
158
|
+
if (zeros >= 2) {
|
|
159
|
+
// Output how many non-zeros we just cooked
|
|
160
|
+
if (nonzeros > 0) {
|
|
161
|
+
// add an overhead byte
|
|
162
|
+
let buffer2 = Buffer.from([0x80 + nonzeros]);
|
|
163
|
+
output = Buffer.concat([output, buffer2]);
|
|
164
|
+
}
|
|
165
|
+
// output code 0xxx xxxx
|
|
166
|
+
let buffer2 = Buffer.from([zeros]);
|
|
167
|
+
output = Buffer.concat([output, buffer2]);
|
|
168
|
+
pos += (zeros-1);
|
|
169
|
+
nonzeros = 0;
|
|
170
|
+
}
|
|
171
|
+
else { // Less than two zeroes here
|
|
172
|
+
// Copy this positions value to output
|
|
173
|
+
nonzeros++;
|
|
174
|
+
let buffer2 = Buffer.from([buf[pos]]);
|
|
175
|
+
output = Buffer.concat([output, buffer2]);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
if (nonzeros == 127 || pos === len-1) {
|
|
179
|
+
if (nonzeros > 0) {
|
|
180
|
+
// Need to dump one extra byte stating how many non-zeros
|
|
181
|
+
let buffer2 = Buffer.from([0x80 + nonzeros]);
|
|
182
|
+
output = Buffer.concat([output, buffer2]);
|
|
183
|
+
nonzeros = 0;
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
pos++;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
console.log("RRLE Result: " + output.length + " bytes, original: " + buf.length + " bytes");
|
|
191
|
+
return output;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
const RRLEDecode = (buf, expectedSize) => {
|
|
195
|
+
let inpos = buf.length;
|
|
196
|
+
let result = Buffer.alloc(expectedSize, 0);
|
|
197
|
+
let outpos = expectedSize;
|
|
198
|
+
while (inpos > 0) {
|
|
199
|
+
if (outpos <= 0)
|
|
200
|
+
throw new Error(`Negative output position`);
|
|
201
|
+
inpos--;
|
|
202
|
+
if (outpos < inpos)
|
|
203
|
+
throw new Error(`Output position before input position: outpos: ${outpos} inpos: ${inpos}`);
|
|
204
|
+
let len = buf[inpos] & 0x7f;
|
|
205
|
+
let zero = (buf[inpos] & 0x80) === 0;
|
|
206
|
+
|
|
207
|
+
if (len > outpos)
|
|
208
|
+
throw new Error(`Invalid length found at inpos ${inpos}, would cause output underrun`);
|
|
209
|
+
|
|
210
|
+
if (!zero) {
|
|
211
|
+
while (len--)
|
|
212
|
+
result[--outpos] = buf[--inpos];
|
|
213
|
+
} else {
|
|
214
|
+
// Skip zeros
|
|
215
|
+
outpos-=len;
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
if (outpos != 0 || inpos != 0)
|
|
219
|
+
throw new Error("Output and input did not end at zero");
|
|
220
|
+
return result;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
let almanacCache;
|
|
224
|
+
let almanacTimestamp_ms;
|
|
225
|
+
|
|
226
|
+
const ALMANAC_CACHE_MAX_AGE_S = 60*60*24;
|
|
227
|
+
|
|
228
|
+
const loadAlmanac = async (args) => {
|
|
229
|
+
const nowMs = new Date().getTime();
|
|
230
|
+
if (almanacCache && almanacTimestamp_ms && nowMs-almanacTimestamp_ms < ALMANAC_CACHE_MAX_AGE_S*1000)
|
|
231
|
+
return almanacCache;
|
|
232
|
+
|
|
233
|
+
if (!args.k)
|
|
234
|
+
return;
|
|
235
|
+
console.log(endpointAlmanac);
|
|
236
|
+
const response = await fetch(endpointAlmanac,
|
|
237
|
+
{
|
|
238
|
+
method:"GET",
|
|
239
|
+
headers: {
|
|
240
|
+
// "Ocp-Apim-Subscription-Key": loraoldcloudapikey,
|
|
241
|
+
"Authorization" : args.k,
|
|
242
|
+
"Content-type" : "application/json"
|
|
243
|
+
},
|
|
244
|
+
})
|
|
245
|
+
.then(response => { if(!response.ok) throw new Error('Login failed - check username and password'); return response.json()})
|
|
246
|
+
.then(data => data)
|
|
247
|
+
.catch(err => {
|
|
248
|
+
console.log("Login failed - connection problem?");
|
|
249
|
+
return null;
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
if (response && response.result && response.result.almanac_image) {
|
|
253
|
+
const buf = Buffer.from(response.result.almanac_image, "base64");
|
|
254
|
+
response.result.almanac_image = buf.toString("hex");
|
|
255
|
+
|
|
256
|
+
// Optionally add a compressed and tested version of the same image to the result
|
|
257
|
+
try {
|
|
258
|
+
const compressed = RRLEEncode(buf);
|
|
259
|
+
if (compressed.length >= buf.length)
|
|
260
|
+
throw new Error("The compressed length exceeds the uncompressed length");
|
|
261
|
+
const decompressed = RRLEDecode(compressed, buf.length);
|
|
262
|
+
if (decompressed.length != buf.length)
|
|
263
|
+
throw new Error("The uncompress yields wrong length");
|
|
264
|
+
const compressed_hex = decompressed.toString('hex');
|
|
265
|
+
if (compressed_hex !== response.result.almanac_image)
|
|
266
|
+
throw new Error("Decompression generated wrong result");
|
|
267
|
+
response.result.almanac_compressed = compressed.toString('hex');
|
|
268
|
+
} catch (err) {
|
|
269
|
+
console.log(err);
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
if (response && response.result && response.result.almanac_image) {
|
|
274
|
+
console.log("ALMANAC ADDED TO CACHE:", response);
|
|
275
|
+
almanacTimestamp_ms = nowMs;
|
|
276
|
+
almanacCache = response;
|
|
277
|
+
}
|
|
278
|
+
return response;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
module.exports.api = {
|
|
282
|
+
solvePosition,
|
|
283
|
+
loadAlmanac,
|
|
284
|
+
checkArgumentsOrExit: (args)=>{if (!args.k) throw new Error("-k <API key> is required for LoraCloud solver"); },
|
|
285
|
+
getVersionString: ()=>"LoraCloud Solver",
|
|
286
|
+
initialize: (args) => {}
|
|
287
|
+
};
|
package/solvers/none.js
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/*
|
|
2
|
+
The MIT License (MIT)
|
|
3
|
+
|
|
4
|
+
Copyright Sensative AB 2023. All rights reserved.
|
|
5
|
+
|
|
6
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
7
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
8
|
+
in the Software without restriction, including without limitation the rights
|
|
9
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
10
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
11
|
+
furnished to do so, subject to the following conditions:
|
|
12
|
+
|
|
13
|
+
The above copyright notice and this permission notice shall be included in all
|
|
14
|
+
copies or substantial portions of the Software.
|
|
15
|
+
|
|
16
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
17
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
18
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
19
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
20
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
21
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
22
|
+
SOFTWARE.
|
|
23
|
+
|
|
24
|
+
This is a solver which has no implementation, e.g. you do not get positioning
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
const loraCloudLoadAlmanac = require('./combain-loracloud').loadAlmanac;
|
|
28
|
+
|
|
29
|
+
module.exports.api = {
|
|
30
|
+
initialize : async (args) => { return undefined; },
|
|
31
|
+
solvePosition : async (args) => {return undefined; },
|
|
32
|
+
loadAlmanac : async (args) => {
|
|
33
|
+
if (!args.k)
|
|
34
|
+
return undefined;
|
|
35
|
+
// The almanac loading procedure may be valid also for non-loracloud solvers
|
|
36
|
+
return loraCloudLoadAlmanac.loadAlmanac(args);
|
|
37
|
+
},
|
|
38
|
+
checkArgumentsOrExit: (args)=>{if (args.k) console.log("No position solver, using loracloud key (-k) for almanac downloads") },
|
|
39
|
+
getVersionString: ()=>"No Position Solver",
|
|
40
|
+
};
|