Skip to content

Instantly share code, notes, and snippets.

@iamphilrae
Created November 19, 2024 16:03
Show Gist options
  • Save iamphilrae/de49d6c7a88d90e552a79d723b29ae7a to your computer and use it in GitHub Desktop.
Save iamphilrae/de49d6c7a88d90e552a79d723b29ae7a to your computer and use it in GitHub Desktop.
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