diff --git a/README.md b/README.md index c716e66..34164b6 100644 --- a/README.md +++ b/README.md @@ -1,25 +1,92 @@ # LocationHub -TODO +**Self-hosted backend for LoRaWAN-based location tracking, supporting multiple location providers such as GNSS, Gateway-Triangulation, WiFi-Triangulation, and BLE-Triangulation.** +We recommend using [The Things Network](https://www.thethingsnetwork.org/) (TTN) as middleware for relaying LoRaWAN payloads. + +--- + +## Table of Contents +1. [Setup](#setup) + - [Prerequisites](#prerequisites) + - [Database](#database) + - [Configuration](#configuration) +2. [TTN Integration](#ttn-integration) +3. [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) + - [Testing Webhook](#testing-webhook) + - [Emulating Wigle API](#emulating-wigle-api) + +--- ## Setup + ### Prerequisites -- Node.js >= 22.11.0 -- Maria DB >= 11.6.2 +Ensure the following dependencies are installed: +- **Node.js** >= 22.11.0 +- **MariaDB** >= 11.6.2 +- A web server, such as **nginx** + +--- ### Database -**Change name of database and credentials as you like!** - +**Customize the database name and credentials to your preference.** +Follow these steps to set up the database: - Create new database: `CREATE DATABASE dev_locationhub;` - 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` +### Configuration +- Copy the .env.template file and rename it to .env: +- Use a strong token/secret for the webhook. +- Add your [Wigle](http://wigle.net) API token to translate MAC addresses to coordinates +- Use [systemd](server/scripts/locationhub.service) to start the server. + ### TTN Integration -Create new Webhook for application. Set base url and enable "Uplink message" to api `/api/ttn/webhook`. +Create new Webhook for your application. Set base url to `https://your.domain.tld` and enable "Uplink message" to api `/api/ttn/webhook`. Add a addidtional header: - Type: `authorization` -- Value: `Bearer your-very-secure-token` +- Value: `Bearer your-very-secure-token-from-the-env-file` +## 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. + +### Onboard SenseCAP T1000-B +1. Download and install the App [SenseCraft](https://play.google.com/store/apps/details?id=cc.seeed.sensecapmate) +2. Skip the user account at startup with `Skip` in the upper right corner +3. Select the `Tracker T1000` +4. Connect to your SenseCAP T1000-B +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` +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 + +### Register SenseCAP T1000-B +1. Open your Application in TTN and navigate to `End devices` +2. Click `Register end device` in the upper right corner +3. Set input method to `Enter end device specifics manually` + - Frequency plan: `Europe 863-870 MHz (SF9 for RX2 - recommended)` + - LoRaWAN version: `LoRaWAN Specification 1.0.4` + - Regional Parameters version: `RP002 Regional Parameters 1.0.3` +4. Place as `JoinEUI` the `AppEUI` +5. Place the `DevEUI` +6. Place the `AppKey` +7. Set a name for end device +8. Click `Register end device` +9. Check if new end device joins the TTN +10. Place the [Uplink Payload Formatter](TTN/sensecap_payload_formater.js) as `Custom Javascript formatter` for new end device + +## Testing ### Testing Webhook -- To test the webhook use the python script `ttn-webhook-dummy.py` to send prerecorded TTN Uplinks. -- To test the script you can use `while true; do echo -e "HTTP/1.1 200 OK\r\nContent-Type: text/plain\r\n\r\nSuccess"; nc -l -p 8080 -q 1; done` \ No newline at end of file +- To test the webhook use the python script [ttn-webhook-dummy.py](server/scripts/ttn-webhook-dummy.py) to send prerecorded TTN Uplinks. +- To test the script you can use `while true; do echo -e "HTTP/1.1 200 OK\r\nContent-Type: text/plain\r\n\r\nSuccess"; nc -l -p 8080 -q 1; done` + +### Emulating Wigle API +- To emulate the Wigle API use the python script [wigle-dummy.py](server/scripts/wigle-dummy.py) to translate MAC addresses to coordinates. + + +Happy tracking! 🎉 \ No newline at end of file diff --git a/TTN/sensecap_payload_formater.js b/TTN/sensecap_payload_formater.js new file mode 100644 index 0000000..d75d9a1 --- /dev/null +++ b/TTN/sensecap_payload_formater.js @@ -0,0 +1,911 @@ +function decodeUplink (input) { + const bytes = input['bytes'] + const fport = parseInt(input['fPort']) + const bytesString = bytes2HexString(bytes) + const originMessage = bytesString.toLocaleUpperCase() + const decoded = { + valid: true, + err: 0, + payload: bytesString, + messages: [] + } + if (fport === 199 || fport === 192) { + decoded.messages.push({fport: fport, payload: bytesString}) + return { data: decoded } + } + let measurement = messageAnalyzed(originMessage) + if (measurement.length === 0) { + decoded.valid = false + return { data: decoded } + } + + for (let message of measurement) { + if (message.length === 0) { + continue + } + let elements = [] + for (let element of message) { + if (element.errorCode) { + decoded.err = element.errorCode + decoded.errMessage = element.error + } else { + elements.push(element) + } + } + if (elements.length > 0) { + decoded.messages.push(elements) + } + } + // decoded.messages = measurement + return { data: decoded } +} + +function messageAnalyzed (messageValue) { + try { + let frames = unpack(messageValue) + let measurementResultArray = [] + for (let i = 0; i < frames.length; i++) { + let item = frames[i] + let dataId = item.dataId + let dataValue = item.dataValue + let measurementArray = deserialize(dataId, dataValue) + measurementResultArray.push(measurementArray) + } + return measurementResultArray + } catch (e) { + return e.toString() + } +} + +function unpack (messageValue) { + let frameArray = [] + + for (let i = 0; i < messageValue.length; i++) { + let remainMessage = messageValue + let dataId = remainMessage.substring(0, 2).toUpperCase() + let dataValue + let dataObj = {} + let packageLen + switch (dataId) { + case '01': + dataValue = remainMessage.substring(2, 94) + messageValue = remainMessage.substring(94) + dataObj = { + 'dataId': dataId, 'dataValue': dataValue + } + break + case '02': + dataValue = remainMessage.substring(2, 32) + messageValue = remainMessage.substring(32) + dataObj = { + 'dataId': dataId, 'dataValue': dataValue + } + break + case '03': + dataValue = remainMessage.substring(2, 64) + messageValue = remainMessage.substring(64) + dataObj = { + 'dataId': dataId, 'dataValue': dataValue + } + break + case '04': + dataValue = remainMessage.substring(2, 20) + messageValue = remainMessage.substring(20) + dataObj = { + 'dataId': dataId, 'dataValue': dataValue + } + break + case '05': + dataValue = remainMessage.substring(2, 10) + messageValue = remainMessage.substring(10) + dataObj = { + 'dataId': dataId, 'dataValue': dataValue + } + break + case '06': + dataValue = remainMessage.substring(2, 44) + messageValue = remainMessage.substring(44) + dataObj = { + 'dataId': dataId, 'dataValue': dataValue + } + break + case '07': + dataValue = remainMessage.substring(2, 84) + messageValue = remainMessage.substring(84) + dataObj = { + 'dataId': dataId, 'dataValue': dataValue + } + break + case '08': + dataValue = remainMessage.substring(2, 70) + messageValue = remainMessage.substring(70) + dataObj = { + 'dataId': dataId, 'dataValue': dataValue + } + break + case '09': + dataValue = remainMessage.substring(2, 36) + messageValue = remainMessage.substring(36) + dataObj = { + 'dataId': dataId, 'dataValue': dataValue + } + break + case '0A': + dataValue = remainMessage.substring(2, 76) + messageValue = remainMessage.substring(76) + dataObj = { + 'dataId': dataId, 'dataValue': dataValue + } + break + case '0B': + dataValue = remainMessage.substring(2, 62) + messageValue = remainMessage.substring(62) + dataObj = { + 'dataId': dataId, 'dataValue': dataValue + } + break + case '0C': + break + case '0D': + dataValue = remainMessage.substring(2, 10) + messageValue = remainMessage.substring(10) + dataObj = { + 'dataId': dataId, 'dataValue': dataValue + } + break + case '0E': + packageLen = getInt(remainMessage.substring(8, 10)) * 2 + 10 + dataValue = remainMessage.substring(2, 8) + remainMessage.substring(10, packageLen) + messageValue = remainMessage.substring(packageLen) + dataObj = { + 'dataId': dataId, 'dataValue': dataValue + } + break + case '0F': + dataValue = remainMessage.substring(2, 34) + messageValue = remainMessage.substring(34) + dataObj = { + 'dataId': dataId, 'dataValue': dataValue + } + break + case '10': + dataValue = remainMessage.substring(2, 26) + messageValue = remainMessage.substring(26) + dataObj = { + 'dataId': dataId, 'dataValue': dataValue + } + break + case '11': + dataValue = remainMessage.substring(2, 28) + messageValue = remainMessage.substring(28) + dataObj = { + 'dataId': dataId, 'dataValue': dataValue + } + break + case '1A': + dataValue = remainMessage.substring(2, 56) + messageValue = remainMessage.substring(56) + dataObj = { + 'dataId': dataId, 'dataValue': dataValue + } + break + case '1B': + dataValue = remainMessage.substring(2, 96) + messageValue = remainMessage.substring(96) + dataObj = { + 'dataId': dataId, 'dataValue': dataValue + } + break + case '1C': + dataValue = remainMessage.substring(2, 82) + messageValue = remainMessage.substring(82) + dataObj = { + 'dataId': dataId, 'dataValue': dataValue + } + break + case '1D': + dataValue = remainMessage.substring(2, 40) + messageValue = remainMessage.substring(40) + dataObj = { + 'dataId': dataId, 'dataValue': dataValue + } + break + default: + return frameArray + } + if (dataValue.length < 2) { + break + } + frameArray.push(dataObj) + } + return frameArray +} + +function deserialize (dataId, dataValue) { + let measurementArray = [] + let eventList = [] + let measurement = {} + let collectTime = 0 + let groupId = 0 + let shardFlag = {} + let payload = '' + let motionId = '' + switch (dataId) { + case '01': + measurementArray = getUpShortInfo(dataValue) + measurementArray.push(...getMotionSetting(dataValue.substring(30, 40))) + measurementArray.push(...getStaticSetting(dataValue.substring(40, 46))) + measurementArray.push(...getShockSetting(dataValue.substring(46, 52))) + measurementArray.push(...getTempSetting(dataValue.substring(52, 72))) + measurementArray.push(...getLightSetting(dataValue.substring(72, 92))) + break + case '02': + measurementArray = getUpShortInfo(dataValue) + break + case '03': + measurementArray.push(...getMotionSetting(dataValue.substring(0, 10))) + measurementArray.push(...getStaticSetting(dataValue.substring(10, 16))) + measurementArray.push(...getShockSetting(dataValue.substring(16, 22))) + measurementArray.push(...getTempSetting(dataValue.substring(22, 42))) + measurementArray.push(...getLightSetting(dataValue.substring(42, 62))) + break + case '04': + let interval = 0 + let workMode = getInt(dataValue.substring(0, 2)) + let heartbeatInterval = getMinsByMin(dataValue.substring(4, 8)) + let periodicInterval = getMinsByMin(dataValue.substring(8, 12)) + let eventInterval = getMinsByMin(dataValue.substring(12, 16)) + switch (workMode) { + case 0: + interval = heartbeatInterval + break + case 1: + interval = periodicInterval + break + case 2: + interval = eventInterval + break + } + measurementArray = [ + {measurementId: '3940', type: 'Work Mode', measurementValue: workMode}, + {measurementId: '3942', type: 'Heartbeat Interval', measurementValue: heartbeatInterval}, + {measurementId: '3943', type: 'Periodic Interval', measurementValue: periodicInterval}, + {measurementId: '3944', type: 'Event Interval', measurementValue: eventInterval}, + {measurementId: '3941', type: 'SOS Mode', measurementValue: getSOSMode(dataValue.substring(16, 18))}, + {measurementId: '3900', type: 'Uplink Interval', measurementValue: interval} + ] + break; + case '05': + measurementArray = [ + {measurementId: '3000', type: 'Battery', measurementValue: getBattery(dataValue.substring(0, 2))}, + {measurementId: '3940', type: 'Work Mode', measurementValue: getWorkingMode(dataValue.substring(2, 4))}, + {measurementId: '3965', type: 'Positioning Strategy', measurementValue: getPositioningStrategy(dataValue.substring(4, 6))}, + {measurementId: '3941', type: 'SOS Mode', measurementValue: getSOSMode(dataValue.substring(6, 8))} + ] + break + case '06': + collectTime = getUTCTimestamp(dataValue.substring(8, 16)) + motionId = getMotionId(dataValue.substring(6, 8)) + measurementArray = [ + {measurementId: '4200', timestamp: collectTime, motionId: motionId, type: 'Event Status', measurementValue: getEventStatus(dataValue.substring(0, 6))}, + {measurementId: '4197', timestamp: collectTime, motionId: motionId, type: 'Longitude', measurementValue: parseFloat(getSensorValue(dataValue.substring(16, 24), 1000000))}, + {measurementId: '4198', timestamp: collectTime, motionId: motionId, type: 'Latitude', measurementValue: parseFloat(getSensorValue(dataValue.substring(24, 32), 1000000))}, + {measurementId: '4097', timestamp: collectTime, motionId: motionId, type: 'Air Temperature', measurementValue: getSensorValue(dataValue.substring(32, 36), 10)}, + {measurementId: '4199', timestamp: collectTime, motionId: motionId, type: 'Light', measurementValue: getSensorValue(dataValue.substring(36, 40))}, + {measurementId: '3000', timestamp: collectTime, motionId: motionId, type: 'Battery', measurementValue: getBattery(dataValue.substring(40, 42))} + ] + break + case '07': + eventList = getEventStatus(dataValue.substring(0, 6)) + collectTime = getUTCTimestamp(dataValue.substring(8, 16)) + motionId = getMotionId(dataValue.substring(6, 8)) + measurementArray = [ + {measurementId: '4200', timestamp: collectTime, motionId: motionId, type: 'Event Status', measurementValue: getEventStatus(dataValue.substring(0, 6))}, + {measurementId: '5001', timestamp: collectTime, motionId: motionId, type: 'Wi-Fi Scan', measurementValue: getMacAndRssiObj(dataValue.substring(16, 72))}, + {measurementId: '4097', timestamp: collectTime, motionId: motionId, type: 'Air Temperature', measurementValue: getSensorValue(dataValue.substring(72, 76), 10)}, + {measurementId: '4199', timestamp: collectTime, motionId: motionId, type: 'Light', measurementValue: getSensorValue(dataValue.substring(76, 80))}, + {measurementId: '3000', timestamp: collectTime, motionId: motionId, type: 'Battery', measurementValue: getBattery(dataValue.substring(80, 82))} + ] + break + case '08': + collectTime = getUTCTimestamp(dataValue.substring(8, 16)) + motionId = getMotionId(dataValue.substring(6, 8)) + measurementArray = [ + {measurementId: '4200', timestamp: collectTime, motionId: motionId, type: 'Event Status', measurementValue: getEventStatus(dataValue.substring(0, 6))}, + {measurementId: '5002', timestamp: collectTime, motionId: motionId, type: 'BLE Scan', measurementValue: getMacAndRssiObj(dataValue.substring(16, 58))}, + {measurementId: '4097', timestamp: collectTime, motionId: motionId, type: 'Air Temperature', measurementValue: getSensorValue(dataValue.substring(58, 62), 10)}, + {measurementId: '4199', timestamp: collectTime, motionId: motionId, type: 'Light', measurementValue: getSensorValue(dataValue.substring(62, 66))}, + {measurementId: '3000', timestamp: collectTime, motionId: motionId, type: 'Battery', measurementValue: getBattery(dataValue.substring(66, 68))} + ] + break + case '09': + collectTime = getUTCTimestamp(dataValue.substring(8, 16)) + motionId = getMotionId(dataValue.substring(6, 8)) + measurementArray = [ + {measurementId: '4200', timestamp: collectTime, motionId: motionId, type: 'Event Status', measurementValue: getEventStatus(dataValue.substring(0, 6))}, + {measurementId: '4197', timestamp: collectTime, motionId: motionId, type: 'Longitude', measurementValue: parseFloat(getSensorValue(dataValue.substring(16, 24), 1000000))}, + {measurementId: '4198', timestamp: collectTime, motionId: motionId, type: 'Latitude', measurementValue: parseFloat(getSensorValue(dataValue.substring(24, 32), 1000000))}, + {measurementId: '3000', timestamp: collectTime, motionId: motionId, type: 'Battery', measurementValue: getBattery(dataValue.substring(32, 34))} + ] + break + case '0A': + collectTime = getUTCTimestamp(dataValue.substring(8, 16)) + motionId = getMotionId(dataValue.substring(6, 8)) + measurementArray = [ + {measurementId: '4200', timestamp: collectTime, motionId, type: 'Event Status', measurementValue: getEventStatus(dataValue.substring(0, 6))}, + {measurementId: '5001', timestamp: collectTime, motionId, type: 'Wi-Fi Scan', measurementValue: getMacAndRssiObj(dataValue.substring(16, 72))}, + {measurementId: '3000', timestamp: collectTime, motionId, type: 'Battery', measurementValue: getBattery(dataValue.substring(72, 74))} + ] + break + case '0B': + collectTime = getUTCTimestamp(dataValue.substring(8, 16)) + motionId = getMotionId(dataValue.substring(6, 8)) + measurementArray = [ + {measurementId: '4200', timestamp: collectTime, motionId, type: 'Event Status', measurementValue: getEventStatus(dataValue.substring(0, 6))}, + {measurementId: '5002', timestamp: collectTime, motionId, type: 'BLE Scan', measurementValue: getMacAndRssiObj(dataValue.substring(16, 58))}, + {measurementId: '3000', timestamp: collectTime, motionId, type: 'Battery', measurementValue: getBattery(dataValue.substring(58, 60))}, + ] + break + case '0D': + let errorCode = getInt(dataValue) + let error = '' + switch (errorCode) { + case 1: + error = 'FAILED TO OBTAIN THE UTC TIMESTAMP' + break + case 2: + error = 'ALMANAC TOO OLD' + break + case 3: + error = 'DOPPLER ERROR' + break + } + measurementArray.push({errorCode, error}) + break + case '0E': + shardFlag = getShardFlag(dataValue.substring(0, 2)) + groupId = getInt(dataValue.substring(2, 6)) + payload = dataValue.substring(6) + measurement = { + measurementId: '6152', + groupId: groupId, + index: shardFlag.index, + count: shardFlag.count, + type: 'gnss-ng payload', + measurementValue: payload + } + measurementArray.push(measurement) + break + case '0F': + collectTime = getUTCTimestamp(dataValue.substring(8, 16)) + shardFlag = getShardFlag(dataValue.substring(26, 28)) + groupId = getInt(dataValue.substring(28, 32)) + motionId = getMotionId(dataValue.substring(6, 8)) + measurementArray.push({ + measurementId: '4200', + timestamp: collectTime, + motionId, + groupId: groupId, + index: shardFlag.index, + count: shardFlag.count, + type: 'Event Status', + measurementValue: getEventStatus(dataValue.substring(0, 6)) + }) + measurementArray.push({ + measurementId: '4097', + timestamp: collectTime, + motionId, + groupId: groupId, + index: shardFlag.index, + count: shardFlag.count, + type: 'Air Temperature', + measurementValue: getSensorValue(dataValue.substring(16, 20), 10) + }) + measurementArray.push({ + measurementId: '4199', + timestamp: collectTime, + motionId, + groupId: groupId, + index: shardFlag.index, + count: shardFlag.count, + type: 'Light', + measurementValue: getSensorValue(dataValue.substring(20, 24)) + }) + measurementArray.push({ + measurementId: '3000', + timestamp: collectTime, + motionId, + groupId: groupId, + index: shardFlag.index, + count: shardFlag.count, + type: 'Battery', + measurementValue: getBattery(dataValue.substring(24, 26)) + }) + break + case '10': + collectTime = getUTCTimestamp(dataValue.substring(8, 16)) + shardFlag = getShardFlag(dataValue.substring(18, 20)) + groupId = getInt(dataValue.substring(20, 24)) + motionId = getMotionId(dataValue.substring(6, 8)) + measurementArray.push({ + measurementId: '4200', + timestamp: collectTime, + motionId, + groupId: groupId, + index: shardFlag.index, + count: shardFlag.count, + type: 'Event Status', + measurementValue: getEventStatus(dataValue.substring(0, 6)) + }) + measurementArray.push({ + measurementId: '3000', + timestamp: collectTime, + motionId, + groupId: groupId, + index: shardFlag.index, + count: shardFlag.count, + type: 'Battery', + measurementValue: getBattery(dataValue.substring(16, 18)) + }) + break + case '11': + collectTime = getUTCTimestamp(dataValue.substring(8, 16)) + measurementArray.push({ + measurementId: '3576', + timestamp: collectTime, + type: 'Positioning Status', + measurementValue: getPositingStatus(dataValue.substring(0, 2)) + }) + measurementArray.push({ + timestamp: collectTime, + measurementId: '4200', + type: 'Event Status', + measurementValue: getEventStatus(dataValue.substring(2, 8)) + }) + if (!isNaN(parseFloat(getSensorValue(dataValue.substring(16, 20), 10)))) { + measurementArray.push({ + timestamp: collectTime, + measurementId: '4097', + type: 'Air Temperature', + measurementValue: getSensorValue(dataValue.substring(16, 20), 10) + }) + } + if (!isNaN(parseFloat(getSensorValue(dataValue.substring(20, 24))))) { + measurementArray.push({ + timestamp: collectTime, + measurementId: '4199', + type: 'Light', + measurementValue: getSensorValue(dataValue.substring(20, 24)) + }) + } + measurementArray.push({ + timestamp: collectTime, + measurementId: '3000', + type: 'Battery', + measurementValue: getBattery(dataValue.substring(24, 26)) + }) + break + case '1A': + collectTime = getUTCTimestamp(dataValue.substring(8, 16)) + motionId = getMotionId(dataValue.substring(6, 8)) + measurementArray = [ + {measurementId: '4200', timestamp: collectTime, motionId, type: 'Event Status', measurementValue: getEventStatus(dataValue.substring(0, 6))}, + {measurementId: '4197', timestamp: collectTime, motionId, type: 'Longitude', measurementValue: parseFloat(getSensorValue(dataValue.substring(16, 24), 1000000))}, + {measurementId: '4198', timestamp: collectTime, motionId, type: 'Latitude', measurementValue: parseFloat(getSensorValue(dataValue.substring(24, 32), 1000000))}, + {measurementId: '4097', timestamp: collectTime, motionId, type: 'Air Temperature', measurementValue: getSensorValue(dataValue.substring(32, 36), 10)}, + {measurementId: '4199', timestamp: collectTime, motionId, type: 'Light', measurementValue: getSensorValue(dataValue.substring(36, 40))}, + {measurementId: '4210', timestamp: collectTime, motionId, type: 'AccelerometerX', measurementValue: getSensorValue(dataValue.substring(40, 44))}, + {measurementId: '4211', timestamp: collectTime, motionId, type: 'AccelerometerY', measurementValue: getSensorValue(dataValue.substring(44, 48))}, + {measurementId: '4212', timestamp: collectTime, motionId, type: 'AccelerometerZ', measurementValue: getSensorValue(dataValue.substring(48, 52))}, + {measurementId: '3000', timestamp: collectTime, motionId, type: 'Battery', measurementValue: getBattery(dataValue.substring(52, 54))}, + ] + break + // WIFI定位数据+sensor+三轴+电量 + case '1B': + collectTime = getUTCTimestamp(dataValue.substring(8, 16)) + motionId = getMotionId(dataValue.substring(6, 8)) + measurementArray = [ + {measurementId: '4200', timestamp: collectTime, motionId, type: 'Event Status', measurementValue: getEventStatus(dataValue.substring(0, 6))}, + {measurementId: '5001', timestamp: collectTime, motionId, type: 'Wi-Fi Scan', measurementValue: getMacAndRssiObj(dataValue.substring(16, 72))}, + {measurementId: '4097', timestamp: collectTime, motionId, type: 'Air Temperature', measurementValue: getSensorValue(dataValue.substring(72, 76), 10)}, + {measurementId: '4199', timestamp: collectTime, motionId, type: 'Light', measurementValue: getSensorValue(dataValue.substring(76, 80))}, + {measurementId: '4210', timestamp: collectTime, motionId, type: 'AccelerometerX', measurementValue: getSensorValue(dataValue.substring(80, 84))}, + {measurementId: '4211', timestamp: collectTime, motionId, type: 'AccelerometerY', measurementValue: getSensorValue(dataValue.substring(84, 88))}, + {measurementId: '4212', timestamp: collectTime, motionId, type: 'AccelerometerZ', measurementValue: getSensorValue(dataValue.substring(88, 92))}, + {measurementId: '3000', timestamp: collectTime, motionId, type: 'Battery', measurementValue: getBattery(dataValue.substring(92, 94))} + ] + break + // BLE定位数据+sensor+三轴+电量 + case '1C': + collectTime = getUTCTimestamp(dataValue.substring(8, 16)) + motionId = getMotionId(dataValue.substring(6, 8)) + measurementArray = [ + {measurementId: '4200', timestamp: collectTime, motionId, type: 'Event Status', measurementValue: getEventStatus(dataValue.substring(0, 6))}, + {measurementId: '5002', timestamp: collectTime, motionId, type: 'BLE Scan', measurementValue: getMacAndRssiObj(dataValue.substring(16, 58))}, + {measurementId: '4097', timestamp: collectTime, motionId, type: 'Air Temperature', measurementValue: getSensorValue(dataValue.substring(58, 62), 10)}, + {measurementId: '4199', timestamp: collectTime, motionId, type: 'Light', measurementValue: getSensorValue(dataValue.substring(62, 66))}, + {measurementId: '4210', timestamp: collectTime, motionId, type: 'AccelerometerX', measurementValue: getSensorValue(dataValue.substring(66, 70))}, + {measurementId: '4211', timestamp: collectTime, motionId, type: 'AccelerometerY', measurementValue: getSensorValue(dataValue.substring(70, 74))}, + {measurementId: '4212', timestamp: collectTime, motionId, type: 'AccelerometerZ', measurementValue: getSensorValue(dataValue.substring(74, 78))}, + {measurementId: '3000', timestamp: collectTime, motionId, type: 'Battery', measurementValue: getBattery(dataValue.substring(78, 80))} + ] + break + // 定位状态 + sensor+三轴数据上报 + case '1D': + collectTime = getUTCTimestamp(dataValue.substring(8, 16)) + measurementArray.push({ + measurementId: '3576', + timestamp: collectTime, + type: 'Positioning Status', + measurementValue: getPositingStatus(dataValue.substring(0, 2)) + }) + measurementArray.push({ + timestamp: collectTime, + measurementId: '4200', + type: 'Event Status', + measurementValue: getEventStatus(dataValue.substring(2, 8)) + }) + if (!isNaN(parseFloat(getSensorValue(dataValue.substring(16, 20), 10)))) { + measurementArray.push({ + timestamp: collectTime, + measurementId: '4097', + type: 'Air Temperature', + measurementValue: getSensorValue(dataValue.substring(16, 20), 10) + }) + } + if (!isNaN(parseFloat(getSensorValue(dataValue.substring(20, 24))))) { + measurementArray.push({ + timestamp: collectTime, + measurementId: '4199', + type: 'Light', + measurementValue: getSensorValue(dataValue.substring(20, 24)) + }) + } + measurementArray.push({ + timestamp: collectTime, + measurementId: '4210', + type: 'AccelerometerX', + measurementValue: getSensorValue(dataValue.substring(24, 28)) + }) + measurementArray.push({ + timestamp: collectTime, + measurementId: '4211', + type: 'AccelerometerY', + measurementValue: getSensorValue(dataValue.substring(28, 32)) + }) + measurementArray.push({ + timestamp: collectTime, + measurementId: '4212', + type: 'AccelerometerZ', + measurementValue: getSensorValue(dataValue.substring(32, 36)) + }) + measurementArray.push({ + timestamp: collectTime, + measurementId: '3000', + type: 'Battery', + measurementValue: getBattery(dataValue.substring(36, 38)) + }) + break + } + return measurementArray +} + +function getMotionId (str) { + return getInt(str) +} + +function getPositingStatus (str) { + let status = getInt(str) + switch (status) { + case 0: + return {id:status, statusName:"Positioning successful."} + case 1: + return {id:status, statusName:"The GNSS scan timed out and failed to obtain the location."} + case 2: + return {id:status, statusName:"The Wi-Fi scan timed out and failed to obtain the location."} + case 3: + return {id:status, statusName:"The Wi-Fi + GNSS scan timed out and failed to obtain the location."} + case 4: + return {id:status, statusName:"The GNSS + Wi-Fi scan timed out and failed to obtain the location."} + case 5: + return {id:status, statusName:"The Bluetooth scan timed out and failed to obtain the location."} + case 6: + return {id:status, statusName:"The Bluetooth + Wi-Fi scan timed out and failed to obtain the location."} + case 7: + return {id:status, statusName:"The Bluetooth + GNSS scan timed out and failed to obtain the location."} + case 8: + return {id:status, statusName:"The Bluetooth + Wi-Fi + GNSS scan timed out and failed to obtain the location."} + case 9: + return {id:status, statusName:"Location Server failed to parse the GNSS location."} + case 10: + return {id:status, statusName:"Location Server failed to parse the Wi-Fi location."} + case 11: + return {id:status, statusName:"Location Server failed to parse the Bluetooth location."} + case 12: + return {id:status, statusName:"Failed to parse the GNSS location due to the poor accuracy."} + case 13: + return {id:status, statusName:"Time synchronization failed."} + case 14: + return {id:status, statusName:"Failed to obtain location due to the old Almanac."} + } + return getInt(str) +} + +function getUpShortInfo (messageValue) { + return [ + { + measurementId: '3000', type: 'Battery', measurementValue: getBattery(messageValue.substring(0, 2)) + }, { + measurementId: '3502', type: 'Firmware Version', measurementValue: getSoftVersion(messageValue.substring(2, 6)) + }, { + measurementId: '3001', type: 'Hardware Version', measurementValue: getHardVersion(messageValue.substring(6, 10)) + }, { + measurementId: '3940', type: 'Work Mode', measurementValue: getWorkingMode(messageValue.substring(10, 12)) + }, { + measurementId: '3965', type: 'Positioning Strategy', measurementValue: getPositioningStrategy(messageValue.substring(12, 14)) + }, { + measurementId: '3942', type: 'Heartbeat Interval', measurementValue: getMinsByMin(messageValue.substring(14, 18)) + }, { + measurementId: '3943', type: 'Periodic Interval', measurementValue: getMinsByMin(messageValue.substring(18, 22)) + }, { + measurementId: '3944', type: 'Event Interval', measurementValue: getMinsByMin(messageValue.substring(22, 26)) + }, { + measurementId: '3945', type: 'Sensor Enable', measurementValue: getInt(messageValue.substring(26, 28)) + }, { + measurementId: '3941', type: 'SOS Mode', measurementValue: getSOSMode(messageValue.substring(28, 30)) + } + ] +} + +function getMotionSetting (str) { + return [ + {measurementId: '3946', type: 'Motion Enable', measurementValue: getInt(str.substring(0, 2))}, + {measurementId: '3947', type: 'Any Motion Threshold', measurementValue: getSensorValue(str.substring(2, 6), 1)}, + {measurementId: '3948', type: 'Motion Start Interval', measurementValue: getMinsByMin(str.substring(6, 10))}, + ] +} + +function getStaticSetting (str) { + return [ + {measurementId: '3949', type: 'Static Enable', measurementValue: getInt(str.substring(0, 2))}, + {measurementId: '3950', type: 'Device Static Timeout', measurementValue: getMinsByMin(str.substring(2, 6))} + ] +} + +function getShockSetting (str) { + return [ + {measurementId: '3951', type: 'Shock Enable', measurementValue: getInt(str.substring(0, 2))}, + {measurementId: '3952', type: 'Shock Threshold', measurementValue: getInt(str.substring(2, 6))} + ] +} + +function getTempSetting (str) { + return [ + {measurementId: '3953', type: 'Temp Enable', measurementValue: getInt(str.substring(0, 2))}, + {measurementId: '3954', type: 'Event Temp Interval', measurementValue: getMinsByMin(str.substring(2, 6))}, + {measurementId: '3955', type: 'Event Temp Sample Interval', measurementValue: getSecondsByInt(str.substring(6, 10))}, + {measurementId: '3956', type: 'Temp ThMax', measurementValue: getSensorValue(str.substring(10, 14), 10)}, + {measurementId: '3957', type: 'Temp ThMin', measurementValue: getSensorValue(str.substring(14, 18), 10)}, + {measurementId: '3958', type: 'Temp Warning Type', measurementValue: getInt(str.substring(18, 20))} + ] +} + +function getLightSetting (str) { + return [ + {measurementId: '3959', type: 'Light Enable', measurementValue: getInt(str.substring(0, 2))}, + {measurementId: '3960', type: 'Event Light Interval', measurementValue: getMinsByMin(str.substring(2, 6))}, + {measurementId: '3961', type: 'Event Light Sample Interval', measurementValue: getSecondsByInt(str.substring(6, 10))}, + {measurementId: '3962', type: 'Light ThMax', measurementValue: getSensorValue(str.substring(10, 14), 10)}, + {measurementId: '3963', type: 'Light ThMin', measurementValue: getSensorValue(str.substring(14, 18), 10)}, + {measurementId: '3964', type: 'Light Warning Type', measurementValue: getInt(str.substring(18, 20))} + ] +} + +function getShardFlag (str) { + let bitStr = getByteArray(str) + return { + count: parseInt(bitStr.substring(0, 4), 2), + index: parseInt(bitStr.substring(4), 2) + } +} + +function getBattery (batteryStr) { + return loraWANV2DataFormat(batteryStr) +} +function getSoftVersion (softVersion) { + return `${loraWANV2DataFormat(softVersion.substring(0, 2))}.${loraWANV2DataFormat(softVersion.substring(2, 4))}` +} +function getHardVersion (hardVersion) { + return `${loraWANV2DataFormat(hardVersion.substring(0, 2))}.${loraWANV2DataFormat(hardVersion.substring(2, 4))}` +} + +function getSecondsByInt (str) { + return getInt(str) +} + +function getMinsByMin (str) { + return getInt(str) +} + +function getSensorValue (str, dig) { + if (str === '8000') { + return null + } else { + return loraWANV2DataFormat(str, dig) + } +} + +function bytes2HexString (arrBytes) { + var str = '' + for (var i = 0; i < arrBytes.length; i++) { + var tmp + var num = arrBytes[i] + if (num < 0) { + tmp = (255 + num + 1).toString(16) + } else { + tmp = num.toString(16) + } + if (tmp.length === 1) { + tmp = '0' + tmp + } + str += tmp + } + return str +} +function loraWANV2DataFormat (str, divisor = 1) { + let strReverse = bigEndianTransform(str) + let str2 = toBinary(strReverse) + if (str2.substring(0, 1) === '1') { + let arr = str2.split('') + let reverseArr = arr.map((item) => { + if (parseInt(item) === 1) { + return 0 + } else { + return 1 + } + }) + str2 = parseInt(reverseArr.join(''), 2) + 1 + return parseFloat('-' + str2 / divisor) + } + return parseInt(str2, 2) / divisor +} + +function bigEndianTransform (data) { + let dataArray = [] + for (let i = 0; i < data.length; i += 2) { + dataArray.push(data.substring(i, i + 2)) + } + return dataArray +} + +function toBinary (arr) { + let binaryData = arr.map((item) => { + let data = parseInt(item, 16) + .toString(2) + let dataLength = data.length + if (data.length !== 8) { + for (let i = 0; i < 8 - dataLength; i++) { + data = `0` + data + } + } + return data + }) + return binaryData.toString().replace(/,/g, '') +} + +function getSOSMode (str) { + return loraWANV2DataFormat(str) +} + +function getMacAndRssiObj (pair) { + let pairs = [] + if (pair.length % 14 === 0) { + for (let i = 0; i < pair.length; i += 14) { + let mac = getMacAddress(pair.substring(i, i + 12)) + if (mac) { + let rssi = getInt8RSSI(pair.substring(i + 12, i + 14)) + pairs.push({mac: mac, rssi: rssi}) + } else { + continue + } + } + } + return pairs +} + +function getMacAddress (str) { + if (str.toLowerCase() === 'ffffffffffff') { + return null + } + let macArr = [] + for (let i = 1; i < str.length; i++) { + if (i % 2 === 1) { + macArr.push(str.substring(i - 1, i + 1)) + } + } + let mac = '' + for (let i = 0; i < macArr.length; i++) { + mac = mac + macArr[i] + if (i < macArr.length - 1) { + mac = mac + ':' + } + } + return mac +} + +function getInt8RSSI (str) { + return loraWANV2DataFormat(str) +} + +function getInt (str) { + return parseInt(str, 16) +} + +function getEventStatus (str) { + // return getInt(str) + let bitStr = getByteArray(str) + let bitArr = [] + for (let i = 0; i < bitStr.length; i++) { + bitArr[i] = bitStr.substring(i, i + 1) + } + bitArr = bitArr.reverse() + let event = [] + for (let i = 0; i < bitArr.length; i++) { + if (bitArr[i] !== '1') { + continue + } + switch (i){ + case 0: + event.push({id:1, eventName:"Start moving event."}) + break + case 1: + event.push({id:2, eventName:"End movement event."}) + break + case 2: + event.push({id:3, eventName:"Motionless event."}) + break + case 3: + event.push({id:4, eventName:"Shock event."}) + break + case 4: + event.push({id:5, eventName:"Temperature event."}) + break + case 5: + event.push({id:6, eventName:"Light event."}) + break + case 6: + event.push({id:7, eventName:"SOS event."}) + break + case 7: + event.push({id:8, eventName:"Press once event."}) + break + } + } + return event +} + +function getByteArray (str) { + let bytes = [] + for (let i = 0; i < str.length; i += 2) { + bytes.push(str.substring(i, i + 2)) + } + return toBinary(bytes) +} + +function getWorkingMode (workingMode) { + return getInt(workingMode) +} + +function getPositioningStrategy (strategy) { + return getInt(strategy) +} + +function getUTCTimestamp(str){ + return parseInt(loraWANV2PositiveDataFormat(str)) * 1000 +} + +function loraWANV2PositiveDataFormat (str, divisor = 1) { + let strReverse = bigEndianTransform(str) + let str2 = toBinary(strReverse) + return parseInt(str2, 2) / divisor +} \ No newline at end of file diff --git a/server/.env.template b/server/.env.template index 2f08814..49bb226 100644 --- a/server/.env.template +++ b/server/.env.template @@ -1,10 +1,18 @@ +# Database DB_NAME="" DB_USER="" DB_PASSWORD="" -DB_HOST="" -DB_DIALECT="" -DB_PORT="" +DB_HOST="localhost" +DB_DIALECT="mariadb" +DB_PORT="3306" + +# Server +PORT="3000" #Port the server runs on, match with your reverse proxy config + +# TTN Webhook WEBHOOK_TOKEN="" #Token that is placed a the TTN Webhook auth + +# Wigle API WIGLE_TOKEN="" # Go to account and generate token "Encoded for use" WIGLE_BASE_URL="https://api.wigle.net" WIGLE_NETWORK_SEARCH="/api/v2/network/search" \ No newline at end of file diff --git a/server/scripts/locationhub.service b/server/scripts/locationhub.service new file mode 100644 index 0000000..7b3b7c0 --- /dev/null +++ b/server/scripts/locationhub.service @@ -0,0 +1,22 @@ +[Unit] +Description=LocationHub +After=network.target systemd-networkd-wait-online.service mysqld.service + +[Service] +Type=simple +User=locationhub +WorkingDirectory=/home/locationhub/git/LocationHub/server/ +ExecStart=/usr/bin/npm run dev +Restart=on-failure +StandardOutput=append:/var/log/LocationHub.log +StandardError=append:/var/log/LocationHub.log + +[Install] +WantedBy=multi-user.target +``` +Activate Systemd Job +``` +systemctl daemon-reload +systemctl enable locationhub.service +systemctl start locationhub.service +systemctl status locationhub.service \ No newline at end of file