Created
November 19, 2024 16:03
-
-
Save iamphilrae/de49d6c7a88d90e552a79d723b29ae7a to your computer and use it in GitHub Desktop.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
let PayloadFormatter = { | |
CODEC: { | |
name: "mokosmart_lw001-bg-pro_chirpstack", | |
version: "1.5.0" | |
}, | |
CONSTANTS: { | |
manufacturer: "MOKOsmart", | |
model: "LW001-BG PRO", | |
ports: { | |
heartbeat: 1, | |
locationFixed: 2, | |
locationFailure: 3, | |
shutdown: 4, | |
shock: 5, | |
manDown: 6, | |
tamper: 7, | |
actionEvent: 8, | |
batteryReport: 9, | |
gpsLimit: 12 | |
} | |
}, | |
messaging: { | |
payloadType: [ // index+1 relate to port numbers, hence the unused 9/10 indexed (port 10/11) | |
"Heartbeat {6da}", // Port 1 | |
"Location Fixed {34f}", // Port 2 | |
"Location Failure {d34}", // Port 3 | |
"Shutdown {3bd}", // Port 4 | |
"Shock Detection {63a}", // Port 5 | |
"Man Down Detection {77d}", // Port 6 | |
"Tamper Detection {cb8}", // Port 7 | |
"Action Event {14a}", // Port 8 | |
"Battery Report {28b}", // Port 9 | |
"Unused {xxx}", // Port 10 | |
"Unused {xxx}", // Port 11 | |
"GNSS Limit {5b8}" // Port 12 | |
], | |
operationMode: [ | |
"Standby Mode {d8e}", | |
"Periodic Mode {9f0}", | |
"Timing Mode {c32}", | |
"Motion Mode {25b}" | |
], | |
rebootReason: [ | |
"Power Failure {c52}", | |
"Bluetooth Request {aab}", | |
"LoRaWAN Request {4bd}", | |
"Normal Power On {5a8}" | |
], | |
positioningRequestType: [ | |
"By Operating Mode {d5b}", | |
"Downlink For Position Command {bf3}" | |
], | |
positioningMethod: [ | |
"Wi-Fi Positioning {bd8}", | |
"Bluetooth Positioning {e23}", | |
"GNSS Positioning {c9e}" | |
], | |
positioningFailedReason: [ | |
"Wi-Fi Positioning Report Interval Too Short {ac5}", | |
"Wi-Fi Positioning Timeout {8e0}", | |
"Wi-Fi Positioning Module Fault {3dd}", | |
"Bluetooth Positioning Report Interval Too Short {fa0}", | |
"Bluetooth Positioning Timeout {5f5}", | |
"Bluetooth Broadcasting in Progress {6f7}", | |
"GNSS Position Time Budget Exceeded {2b4}", | |
"GNSS Positioning Timeout (Coarse) {12d}", | |
"GNSS Positioning Timeout (Fine) {b5b}", | |
"GNSS Positioning Report Interval Too Short {9a9}", | |
"GNSS Positioning Aiding Timeout {e84}", | |
"GNSS Positioning Timeout (Poor Signal) {e92}", | |
"Interrupted by Downlink for Position {a32}", | |
"Interrupted by Movement (Ended Too Quickly) {0f0}", | |
"Interrupted by Movement (Restarted Too Quickly) {d91}" | |
], | |
shutdownType: [ | |
"Bluetooth Command {64d}", | |
"LoRaWAN Command {16e}", | |
"Magnetic Switch {b78}" | |
], | |
eventType: [ | |
"Movement Started {a21}", | |
"Movement In Progress {ee2}", | |
"Movement Ended {2cf}", | |
"Downlink Command {b93}" | |
], | |
batteryLevel: [ | |
"Normal {b22}", | |
"Low Battery {aae}" | |
], | |
manDownStatus: [ | |
"Not Triggered {d8b}", | |
"Man Down Detected {b8f}" | |
], | |
movementSinceLastPayload: [ | |
"No Movement {c65}", | |
"Movement Detected {bcc}" | |
], | |
shockDetector: [ | |
"Not Triggered {a2a}", | |
"Shock Detected {ef8}" | |
], | |
tamperAlarm: [ | |
"Not Triggered {a24}", | |
"Tamper Detected {6df}" | |
] | |
}, | |
_bytesToHexString: function(bytes, start=0, length=null) { | |
if (length === null) | |
length = bytes.length - start; | |
let char = []; | |
for (let i = 0; i < length; i++) { | |
let data = bytes[start + i].toString(16); | |
let dataHexStr = ("0x" + data) < 0x10 ? ("0" + data) : data; | |
char.push(dataHexStr); | |
} | |
return char.join(""); | |
}, | |
_bytesToInt: function(bytes, start, len) { | |
let value = 0; | |
for (let i = 0; i < len; i++) { | |
let m = ((len - 1) - i) * 8; | |
value = value | bytes[start + i] << m; | |
} | |
return value; | |
}, | |
_hexToBytes: function(hex) { | |
let length = hex.length; | |
let bytes = []; | |
for (let i = 0; i < length; i += 2) { | |
let start = i; | |
let end = i + 2; | |
let data = parseInt("0x" + hex.substring(start, end)); | |
bytes.push(data); | |
} | |
return bytes; | |
}, | |
_substringBytes: function(bytes, start, len) { | |
let char = []; | |
for (let i = 0; i < len; i++) { | |
char.push("0x" + bytes[start + i].toString(16) < 0X10 ? ("0" + bytes[start + i].toString(16)) : bytes[start + i].toString(16)); | |
} | |
return char.join(""); | |
}, | |
_signedHexToInt: function (hexStr) { | |
let twoStr = parseInt(hexStr, 16).toString(2); | |
let bitNum = hexStr.length * 4; | |
if (twoStr.length < bitNum) { | |
while (twoStr.length < bitNum) { | |
twoStr = "0" + twoStr; | |
} | |
} | |
if (twoStr.substring(0, 1) === "0") { | |
twoStr = parseInt(twoStr, 2); | |
return twoStr; | |
} | |
twoStr = parseInt(twoStr, 2) - 1; | |
twoStr = twoStr.toString(2); | |
let twoStr_unsign = twoStr.substring(1, bitNum); | |
twoStr_unsign = twoStr_unsign.replace(/0/g, "z"); | |
twoStr_unsign = twoStr_unsign.replace(/1/g, "0"); | |
twoStr_unsign = twoStr_unsign.replace(/z/g, "1"); | |
twoStr = parseInt(-twoStr_unsign, 2); | |
return twoStr; | |
}, | |
_timezoneDecode: function(tz) { | |
let tzStr = ""; | |
tz = (tz > 128) ? tz - 256 : tz; | |
if (tz < 0) { | |
tzStr += "-"; | |
tz = -tz; | |
} else { | |
tzStr += "+"; | |
} | |
if (tz < 20) | |
tzStr += "0"; | |
tzStr += String(parseInt(tz / 2)) + ":"; | |
tzStr += (tz % 2) ? "30" : "00"; | |
if(tzStr === "+00:00" || tzStr === "-00:00") | |
tzStr = "Z"; | |
return tzStr; | |
}, | |
_parseTimeToDTString: function(timestamp, timezone) { | |
timezone = timezone > 64 ? timezone - 128 : timezone; | |
timestamp = timestamp + timezone * 3600; | |
if (timestamp < 0) | |
timestamp = 0; | |
let d = new Date(timestamp * 1000); | |
return d.getUTCFullYear() | |
+ "-" + this._doubleDigitNumber(d.getUTCMonth() + 1) | |
+ "-" + this._doubleDigitNumber(d.getUTCDate()) | |
+ "T" + this._doubleDigitNumber(d.getUTCHours()) | |
+ ":" + this._doubleDigitNumber(d.getUTCMinutes()) | |
+ ":" + this._doubleDigitNumber(d.getUTCSeconds()); | |
}, | |
_convertToISO8601TimezoneOffset: function(offset) { | |
// Convert the string to a number | |
let num = parseFloat(offset); | |
// Calculate the absolute hours and minutes | |
let hours = Math.floor(Math.abs(num)); | |
let minutes = Math.round((Math.abs(num) - hours) * 60); | |
// Format hours and minutes to be two digits | |
let formattedHours = String(hours).padStart(2, '0'); | |
let formattedMinutes = String(minutes).padStart(2, '0'); | |
// Determine the sign (either "+" or "-") | |
let sign = num >= 0 ? "+" : "-"; | |
// Return the formatted timezone offset | |
return `${sign}${formattedHours}:${formattedMinutes}`; | |
}, | |
_doubleDigitNumber: function(number) { | |
return number.toString().padStart(2, '0'); | |
}, | |
/** | |
* Primary function to be used by the ChirpStack. | |
* | |
* @param {Object} input - The input object. | |
* @param {string} input.bytes - Byte array containing the uplink payload, e.g. [255, 230, 255, 0]. | |
* @param {string} input.fPort - Uplink fPort. | |
* @param {string} input.variables - Object containing the configured device variables. | |
* | |
* @returns {Object} output - The output object, containing a "data" parameter of the decoded payload. | |
*/ | |
formatPayload: function(input) | |
{ | |
let output = {data: {}}; | |
/* | |
* Common Payload Header | |
*/ | |
output.data = { | |
codec: this.CODEC, | |
payload: {}, | |
device: {} | |
} | |
output.data.payload.hexadecimal = this._bytesToHexString(input.bytes); | |
output.data.device.manufacturer = this.CONSTANTS.manufacturer; | |
output.data.device.model = this.CONSTANTS.model; | |
let date = new Date(); | |
let timestamp = Math.trunc(date.getTime() / 1000); | |
let offsetHours = Math.abs(Math.floor(date.getTimezoneOffset() / 60)); | |
output.data.payload.timestamp = this._parseTimeToDTString(timestamp, offsetHours) + this._timezoneDecode((offsetHours * 2)); | |
let dataPort = parseInt(input.fPort); | |
output.data.payload.port = dataPort; | |
// Invalid ports | |
if (dataPort < 1 || dataPort >= 13 || dataPort === 10 || dataPort === 11) | |
return output; | |
output.data.payload.type = this.messaging.payloadType[(dataPort - 1)]; | |
output.data.device.operatingMode = this.messaging.operationMode[input.bytes[0] & 0x03]; | |
let isBatteryLevelLow = (input.bytes[0] & 0x04); | |
output.data.device.battery = {}; | |
output.data.device.battery.level = this.messaging.batteryLevel[isBatteryLevelLow ? 1 : 0]; | |
output.data.sensors = {}; | |
let isTamperDetected = (input.bytes[0] & 0x08); | |
output.data.sensors.tamper = {}; | |
output.data.sensors.tamper.status = this.messaging.tamperAlarm[isTamperDetected ? 1 : 0]; | |
let isManDownDetected = (input.bytes[0] & 0x10); | |
output.data.sensors.manDown = {}; | |
output.data.sensors.manDown.status = this.messaging.manDownStatus[isManDownDetected ? 1 : 0]; | |
let isMovementDetected = (input.bytes[0] & 0x20); | |
output.data.sensors.movement = {}; | |
output.data.sensors.movement.status = this.messaging.movementSinceLastPayload[isMovementDetected ? 1 : 0] | |
output.data.sensors.shock = {}; | |
output.data.sensors.shock.status = this.messaging.shockDetector[(dataPort === 5 ? 1 : 0)]; | |
if(dataPort === this.CONSTANTS.ports.locationFixed || dataPort === this.CONSTANTS.ports.locationFailure) { | |
output.data.location = {}; | |
let isRequestByDownlink = (input.bytes[0] & 0x40); | |
output.data.location.positioningRequestType = this.messaging.positioningRequestType[isRequestByDownlink ? 1 : 0] | |
} | |
if(dataPort === 12) { | |
output.data.payload.frameCount = input.bytes[1] & 0x0f; | |
output.data.device.battery.voltage = (22 + ((input.bytes[1] >> 4) & 0x0f)) / 10; | |
// No temperature for port 12? | |
} | |
else { | |
output.data.payload.frameCount = input.bytes[2] & 0x0f; | |
output.data.device.battery.voltage = (22 + ((input.bytes[2] >> 4) & 0x0f)) / 10; | |
output.data.sensors.temperature = { | |
environment: { | |
celsius: this._signedHexToInt(this._bytesToHexString(input.bytes, 1, 1)) | |
} | |
} | |
} | |
/** | |
* Port-specific payloads. | |
*/ | |
switch(dataPort) | |
{ | |
// Heartbeat Payload | |
case this.CONSTANTS.ports.heartbeat : | |
output.data.device.rebootReason = this.messaging.rebootReason[this._bytesToInt(input.bytes, 3, 1)]; | |
output.data.device.firmwareVersion = "v" | |
+ ((input.bytes[4] >> 6) & 0x03) | |
+ "." + ((input.bytes[4] >> 4) & 0x03) | |
+ "." + (input.bytes[4] & 0x0f); | |
break; | |
// Location Payload (Success) | |
case this.CONSTANTS.ports.locationFixed : { | |
output.data.location.status = "SUCCESS"; | |
let parseLength = 3; | |
let positioningMethodCode = parseInt(input.bytes[parseLength++]); | |
output.data.location.positioningMethod = this.messaging.positioningMethod[positioningMethodCode]; | |
let dt = []; | |
dt['year'] = this._bytesToInt(input.bytes, parseLength, 2); | |
parseLength += 2; | |
dt['month'] = this._doubleDigitNumber(input.bytes[parseLength++]); | |
dt['day'] = this._doubleDigitNumber(input.bytes[parseLength++]); | |
dt['hour'] = this._doubleDigitNumber(input.bytes[parseLength++]); | |
dt['minute'] = this._doubleDigitNumber(input.bytes[parseLength++]); | |
dt['second'] = this._doubleDigitNumber(input.bytes[parseLength++]); | |
dt['timezone'] = input.bytes[parseLength++]; | |
let dtString = (dt['year'] + "-" + dt['month'] + "-" + dt['day'] + "T" + dt['hour'] + ":" + dt['minute'] + ":" + dt['second']); | |
output.data.location.timestamp = (dt['timezone'] > 0x80) | |
? dtString + this._convertToISO8601TimezoneOffset((dt['timezone'] - 0x100)) | |
: dtString + this._convertToISO8601TimezoneOffset(dt['timezone']) | |
let dataLength = input.bytes[parseLength++]; | |
switch(positioningMethodCode) | |
{ | |
case 0 : // Wi-Fi Positioning | |
case 1 : // Bluetooth Positioning | |
{ | |
let beacons = []; | |
for (let i = 0; i < (dataLength / 7); i++) | |
{ | |
let beacon = {}; | |
beacon.macAddress = this._substringBytes(input.bytes, parseLength, 6); | |
parseLength += 6; | |
beacon.signalDbm = input.bytes[parseLength++] - 256; | |
beacons.push(beacon); | |
} | |
if (positioningMethodCode === 1) | |
output.data.location.beacons = beacons; | |
else | |
output.data.location.accessPoints = beacons; | |
break; | |
} | |
default : // GPS Positioning | |
{ | |
let lat = this._bytesToInt(input.bytes, parseLength, 4); | |
parseLength += 4; | |
let lon = this._bytesToInt(input.bytes, parseLength, 4); | |
output.data.location.coordinates = { | |
latitude: (((lat > 0x80000000) ? lat - 0x100000000 : lat) / 10000000), | |
longitude: (((lon > 0x80000000) ? lon - 0x100000000 : lon) / 10000000) | |
}; | |
parseLength += 4; | |
output.data.location.pdop = parseFloat((input.bytes[parseLength] / 10).toFixed(1)); | |
} | |
} | |
break; | |
} | |
// Location Payload (Failure) | |
case this.CONSTANTS.ports.locationFailure : { | |
output.data.location = {}; | |
output.data.location.status = "FAILURE"; | |
let parseLength = 3; | |
let positioningFailedCode = this._bytesToInt(input.bytes, parseLength++, 1); | |
output.data.location.reason = this.messaging.positioningFailedReason[positioningFailedCode]; | |
let dataLength = input.bytes[parseLength++]; | |
if (positioningFailedCode <= 5) // 0,1,2,3,4,5 are Wi-Fi Positioning or Bluetooth Positioning | |
{ | |
if (positioningFailedCode >= 3) // 3,4,5 are Bluetooth | |
output.data.location.positioningMethod = this.messaging.positioningMethod[1]; | |
else // 0,1,2 are Wi-Fi | |
output.data.location.positioningMethod = this.messaging.positioningMethod[0]; | |
break; | |
} | |
else // 6+ indexes are GPS Positioning | |
{ | |
let pdop = input.bytes[parseLength++]; | |
if (pdop !== 0xff && pdop !== null && typeof pdop === 'number') | |
output.data.location.pdop = parseFloat((pdop / 10).toFixed(1)); | |
else | |
output.data.location.pdop = "unknown"; | |
if(output.data.location.pdop === null) | |
output.data.location.pdop = "unknown"; | |
let satellites = []; | |
for (let k = 0; k < 4; k++) | |
if (typeof input.bytes[parseLength + k] !== 'undefined') | |
if(parseInt(input.bytes[parseLength + k]) > 0) { | |
let satellite = {} | |
satellite.cnsDbm = parseInt(input.bytes[parseLength + k]); | |
if( satellite.cnsDbm !== 0 ) | |
satellites.push(satellite); | |
} | |
output.data.location.satellites = satellites; | |
output.data.location.positioningMethod = this.messaging.positioningMethod[2]; | |
} | |
break; | |
} | |
// Shutdown Payload | |
case this.CONSTANTS.ports.shutdown : | |
output.data.payload.shutdownType = this.messaging.shutdownType[this._bytesToInt(input.bytes, 3, 1)]; | |
break; | |
// Shock Detector Payload | |
case this.CONSTANTS.ports.shock : | |
output.data.sensors.shock.count = this._bytesToInt(input.bytes, 3, 2); | |
break; | |
// Man-Down Detector Payload | |
case this.CONSTANTS.ports.manDown : | |
output.data.sensors.manDown.idleTime = this._bytesToInt(input.bytes, 3, 2); | |
break; | |
// Tamper Detector Payload | |
case this.CONSTANTS.ports.tamper : { | |
let parseLength = 3; | |
let dt = []; | |
dt['year'] = this._bytesToInt(input.bytes, parseLength, 2); | |
parseLength += 2; | |
dt['month'] = this._doubleDigitNumber(input.bytes[parseLength++]); | |
dt['day'] = this._doubleDigitNumber(input.bytes[parseLength++]); | |
dt['hour'] = this._doubleDigitNumber(input.bytes[parseLength++]); | |
dt['minute'] = this._doubleDigitNumber(input.bytes[parseLength++]); | |
dt['second'] = this._doubleDigitNumber(input.bytes[parseLength++]); | |
dt['timezone'] = input.bytes[parseLength++]; | |
let dtString = (dt['year'] + "-" + dt['month'] + "-" + dt['day'] + "T" + dt['hour'] + ":" + dt['minute'] + ":" + dt['second']); | |
output.data.sensors.tamper.timestamp = (dt['timezone'] > 0x80) | |
? dtString + this._convertToISO8601TimezoneOffset((dt['timezone'] - 0x100)) | |
: dtString + this._convertToISO8601TimezoneOffset(dt['timezone']) | |
break; | |
} | |
// Action Event Payload | |
case this.CONSTANTS.ports.actionEvent : | |
output.data.payload.eventType = this.messaging.eventType[this._bytesToInt(input.bytes, 3, 1)]; | |
break; | |
// Battery Report Payload | |
case this.CONSTANTS.ports.batteryReport : { | |
let parseLength = 3; | |
output.data.payload.usageReport = { | |
broadcasting: {}, | |
positioning: {}, | |
lora: {} | |
}; | |
output.data.payload.usageReport.positioning = {}; | |
output.data.payload.usageReport.positioning.gps = this._bytesToInt(input.bytes, parseLength, 4); | |
parseLength += 4; | |
output.data.payload.usageReport.positioning.wifi = this._bytesToInt(input.bytes, parseLength, 4); | |
parseLength += 4; | |
output.data.payload.usageReport.positioning.bluetooth = this._bytesToInt(input.bytes, parseLength, 4); | |
parseLength += 4; | |
output.data.payload.usageReport.broadcasting.bluetooth = this._bytesToInt(input.bytes, parseLength, 4); | |
parseLength += 4; | |
output.data.payload.usageReport.lora.sending = this._bytesToInt(input.bytes, parseLength, 4); | |
break; | |
} | |
// GPS Limit Payload | |
case this.CONSTANTS.ports.gpsLimit : { | |
output.data.location = {}; | |
output.data.location.status = "SUCCESS"; | |
output.data.location.positioningMethod = this.messaging.positioningMethod[2]; // 2 == GPS Positioning | |
output.data.location.positioningRequestType = this.messaging.positioningRequestType[(input.bytes[0] & 0x40)]; | |
let parseLength = 2 | |
let lat = this._bytesToInt(input.bytes, parseLength, 4); | |
parseLength += 4; | |
let lon = this._bytesToInt(input.bytes, parseLength, 4); | |
output.data.location.coordinates = { | |
latitude: (((lat > 0x80000000) ? lat - 0x100000000 : lat) / 10000000), | |
longitude: (((lon > 0x80000000) ? lon - 0x100000000 : lon) / 10000000) | |
}; | |
parseLength += 4; | |
output.data.location.pdop = parseFloat((input.bytes[parseLength] / 10).toFixed(1)); | |
break; | |
} | |
} | |
return output; | |
} | |
}; | |
/** | |
* Main function that decodes the uplink messages. | |
* | |
* @param {Object} input - The input object. | |
* @param {string} input.bytes - Byte array containing the uplink payload, e.g. [255, 230, 255, 0]. | |
* @param {string} input.fPort - Uplink fPort. | |
* @param {string} input.variables - Object containing the configured device variables. | |
* | |
* @returns {Object} output - The output object, containing a "data" parameter of the decoded payload. | |
*/ | |
function decodeUplink(input) { | |
return PayloadFormatter.formatPayload(input); | |
} | |
/** | |
* CLI arguments to run the script on the CLI. | |
*/ | |
if (typeof process !== 'undefined' | |
&& process.release.name === 'node' | |
&& !process.env.JEST_WORKER_ID ) | |
{ | |
const args = process.argv.slice(2); | |
const port = args[0]; | |
const bytes = PayloadFormatter._hexToBytes(args[1]); | |
console.dir( | |
PayloadFormatter.formatPayload({ | |
fPort: port, | |
bytes: bytes | |
}), | |
{ depth: null } | |
); | |
} | |
/** | |
* Export for testing purposes. | |
* !!! MUST COMMENT OUT FOR DEPLOYMENT !!! | |
*/ | |
//module.exports = { PayloadFormatter }; |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment