Using Datacake Service to Visualize ACR-EX Readings
This is an article about integration of ACR-EX pulse reader converter on the Datacake platform.
Introduction
Datacake is a low-code IoT platform allowing for simple application of IoT devices. In this tutorial, an integration of the pulse ACR-EX converter device.
ACR-EX is a low-power consumption converter device equipped with an 8 digit LCD screen used to aggregate S0 input meter data - primarily used for gas metering.
ACR-EX is capable to aggregate entries within its archive at 15 minute interval, while using MQTT protocol to forward them once a day. In case of signal issues, the device is capable to maintain several thousand entries and sending them once resolved, ensuring that no data loss has occurred.
Creating a Device (ACR-EX)
In order to successfully set up an ACR-EX device on Datacake, please, make sure you have an account there. Once your account is set, follow these steps to successfully integrate the device and to receive and visualise the data.
Step A - Adding the Device
- Click on Devices in the left most menu
- Click on Add Device button in the upper right corner
Step B - Selecting the Device Type
- The window should pop-up, select API
- Click on Next
Step C - Adding New Product
- Click on New Product
- Name the product according to your needs, ACR-EX Pulse Converter in this case
- Click on Next
Step D - Adding a Device Itself
- Fill in the device Serial Number, additionally you may enter other info such as location (can be done later)
- Click on Next
Step E - Plan Selection
- Select a Plan and click on Add 1 device
- Now the device setup should be complete.
Configuring of the Device Board
Step A - Device Selection
- Click on Devices in the left most menu
- Select the Device you want to configure, ACR-EX Pulse Converter in this case
Step B - Configuration Tab
- Click on Configuration
Step C - Scrolling Down to Decoder
- Scroll down until you see HTTP Payload Decoder
Step D - Setting up the Decoder
- Copy the following code into the HTTP Payload Decoder
Javascript parser code
Copy the following code - see copy symbol in the upper right corner
// ES5-Compatible Binary Parser
var ratios = {
"1000000": "1000000:1",
"100000": "100000:1",
"10000": "10000:1",
"1000": "1000:1",
"100": "100:1",
"10": "10:1",
"1": "1:1",
"-10": "1:10",
"-100": "1:100",
"-1000": "1:1000",
"-10000": "1:10000",
"-100000": "1:100000",
"-1000000": "1:1000000",
};
// Helper functions
function readUInt8(array, offset) {
return array[offset] & 0xFF;
}
function readInt8(array, offset) {
var value = array[offset];
return value > 0x7F ? value - 0x100 : value;
}
function readUInt16LE(array, offset) {
return (array[offset] & 0xFF) | ((array[offset + 1] & 0xFF) << 8);
}
function readInt16LE(array, offset) {
var value = (array[offset] & 0xFF) | ((array[offset + 1] & 0xFF) << 8);
return value > 0x7FFF ? value - 0x10000 : value;
}
function readUInt32LE(array, offset) {
return ((array[offset] & 0xFF) |
((array[offset + 1] & 0xFF) << 8) |
((array[offset + 2] & 0xFF) << 16) |
((array[offset + 3] & 0xFF) << 24)) >>> 0;
}
function readInt32LE(array, offset) {
var value = (array[offset] & 0xFF) |
((array[offset + 1] & 0xFF) << 8) |
((array[offset + 2] & 0xFF) << 16) |
((array[offset + 3] & 0xFF) << 24);
return value > 0x7FFFFFFF ? value - 0x100000000 : value;
}
// Simulate 64-bit unsigned (with Number limitations)
function readBigUInt64LE(array, offset) {
var low = readUInt32LE(array, offset);
var high = readUInt32LE(array, offset + 4);
return high * 4294967296 + low;
}
function bufferToAsciiString(array, start, end) {
var str = '';
var off = null;
for (var i = start; i < end; i++) {
if (array[i] === 0) { off = i + 1; break; }
str += String.fromCharCode(array[i]);
}
return { string: str, offset: off };
}
function bufferToHexString(array, start, end) {
var str = '';
for (var i = start; i < end; i++) {
if (array[i] === 0) break;
var h = array[i].toString(16);
str += (h.length === 1 ? '0' + h : h);
}
return str;
}
function convertArrayToHex(array) {
var hexs = [];
for (var i = 0; i < array.length; i++) {
var h = array[i].toString(16);
hexs.push(h.length === 1 ? '0' + h : h);
}
return hexs.join(' ');
}
// Command byte to name mapping
var commandByteNames = {
0xA5: "Periodic report",
0xA6: "Periodic report with a Coulomb counter",
0xA7: "Periodic status report",
0xA8: "Periodic status report with a serial number",
0xAA: "Read the archive",
0xCC: "Read the counter",
0xDA: "Read a device info",
0xDD: "Read a ratio",
0xA0: "Ping",
0xEE: "Signal tester",
0xEF: "Signal tester with a Coulomb counter",
0xB0: "Send a second of the day",
0xB1: "Send a second of the day - spread",
0xB2: "Read a display count time",
0xB3: "Read a display date time",
0xB4: "Read a maximum detector period",
0xB5: "Read a sampling period",
0xB6: "Read the config",
0xB7: "Read an APN",
0xB8: "Read an IP",
0xB9: "Read a port",
0xBA: "Read a PLMN ID",
0xBB: "Read an ID",
0xBC: "Read mode",
0xBD: "Read UNITSTR",
0xBE: "Read OBIS",
0xBF: "Clear the archive acknowledgment",
0xE0: "CRC16 failed",
0xD0: "Read a LWM2M endpoint",
0xD1: "Read a LWM2M server URL",
0xD2: "Read a LWM2M server port",
0xD3: "Read a LWM2M local port",
0xD4: "Read a LWM2M lifetime",
0xD5: "Read a LWM2M PSK ID",
0xD6: "Read a LWM2M PSK",
0xC0: "Read a battery capacity",
0xC1: "Read a history period length",
0xC2: "Read a signal tester period",
0xC3: "Read a signal tester mode",
0xC4: "Read a signal tester payload length",
0xC5: "Read a meter ID",
0xC6: "Read a timezone minutes",
0xC7: "Read a system time",
0xC8: "Read an initial baudrate",
0xC9: "Coulomb Counter reset acknowledgment",
0xCA: "Read a display decimal places",
0xCB: "Read an extended display enable",
0xCE: "Read an IEC password enable",
0xFF: "Error"
};
/**
* Converts an even-length hex string into an array of byte-values (0–255).
* @param {string} hex e.g. "AABBCC"
* @returns {number[]} [170, 187, 204]
*/
function hexToBytes(hex) {
if (hex.length % 2 !== 0) {
throw new Error('hex string must have an even number of characters');
}
var bytes = [];
for (var i = 0; i < hex.length; i += 2) {
var pair = hex.substr(i, 2);
var byte = parseInt(pair, 16);
if (isNaN(byte)) {
throw new Error('Invalid hex digit: "' + pair + '"');
}
bytes.push(byte);
}
return bytes;
}
/**
* Is this a string (primitive or String object)?
* @param {*} x
* @returns {boolean}
*/
function isString(x) {
// 1) catches "" and typeof-String
if (typeof x === 'string') {
return true;
}
// 2) also catch new String("…")
return Object.prototype.toString.call(x) === '[object String]';
}
// Main parser
function parseExPayload(binData, msgType) {
try {
if(isString(binData))
{
binData = hexToBytes(binData);
}
return (msgType === 'uplink') ? parseUplink(binData) : parseDownlink(binData);
} catch (err) {
return {
error: err.message || "Unknown error during parsing.",
direction: msgType,
rawData: convertArrayToHex(binData).toUpperCase()
};
}
// Uplink parser
function parseUplink(binData) {
if (binData.length < 2) {
return {
error: "Incomplete message: Expected at least 2 bytes, got " + binData.length,
direction: msgType,
rawData: convertArrayToHex(binData).toUpperCase()
};
}
var meta = readCustomID(binData);
var cmd = meta.commandByte;
var payload = binData.slice(meta.offset);
var data;
switch (cmd) {
case 0xA5: data = parsePeriodicReport(payload); break;
case 0xA6: data = parsePeriodicReportCC(payload); break;
case 0xA7: data = parsePeriodicStatusReport(payload); break;
case 0xA8: data = parsePeriodicStatusReportSN(payload); break;
case 0xAA: data = parseReadArchive(payload); break;
case 0xCC: data = parseRegularUIntCommand(payload, 'count'); break;
case 0xDA: data = parseDeviceInfo(payload); break;
case 0xDD: data = parseRatio(payload); break;
case 0xA0: data = parseRegularUIntCommand(payload); break;
case 0xEE: data = parseSignalTester(payload); break;
case 0xEF: data = parseSignalTesterCC(payload); break;
case 0xB0: data = parseRegularUIntCommand(payload, 'sendSecondOfDay'); break;
case 0xB1: data = parseRegularUIntCommand(payload, 'sendSecondOfDaySpread'); break;
case 0xB2: data = parseRegularUIntCommand(payload, 'displayCountTime'); break;
case 0xB3: data = parseRegularUIntCommand(payload, 'displayDateTime'); break;
case 0xB4: data = parseRegularUIntCommand(payload, 'maximumDetectorPeriod'); break;
case 0xB5: data = parseRegularUIntCommand(payload, 'samplingPeriodSeconds'); break;
case 0xB6: data = parseConfig(payload); break;
case 0xB7: data = parseRegularStringCommand(payload, 'customAPN'); break;
case 0xB8: data = parseRegularStringCommand(payload, 'customIP'); break;
case 0xB9: data = parseRegularUIntCommand(payload, 'customPort'); break;
case 0xBA: data = parseRegularUIntCommand(payload, 'customPLMNID'); break;
case 0xBB: data = parseRegularStringCommand(payload, 'customID'); break;
case 0xBC: data = parseRegularUIntCommand(payload, 'mode'); break;
case 0xBD: data = parseRegularStringCommand(payload, 'unitStr'); break;
case 0xBE: data = parseRegularStringCommand(payload, 'obisCode'); break;
case 0xBF: data = parseRegularUIntCommand(payload); break;
case 0xE0: data = parseRegularUIntCommand(payload); break;
case 0xD0: data = parseRegularStringCommand(payload, 'LWM2MEndpoint'); break;
case 0xD1: data = parseRegularStringCommand(payload, 'LWM2MServerURL'); break;
case 0xD2: data = parseRegularUIntCommand(payload, 'LWM2MServerPort'); break;
case 0xD3: data = parseRegularUIntCommand(payload, 'LWM2MLocalPort'); break;
case 0xD4: data = parseRegularUIntCommand(payload, 'LWM2MLifetime'); break;
case 0xD5: data = parseRegularStringCommand(payload, 'LWM2MPSKID'); break;
case 0xD6: data = parseRegularStringCommand(payload, 'LWM2MPSK'); break;
case 0xC0: data = parseRegularUIntCommand(payload, 'batteryCapacity'); break;
case 0xC1: data = parseRegularUIntCommand(payload, 'historyPeriodLen'); break;
case 0xC2: data = parseRegularUIntCommand(payload, 'sigTesterPeriod'); break;
case 0xC3: data = parseRegularUIntCommand(payload, 'sigTesterMode'); break;
case 0xC4: data = parseRegularUIntCommand(payload, 'sigTesterPayloadLen'); break;
case 0xC5: data = parseRegularStringCommand(payload, 'meterID'); break;
case 0xC6: data = parseRegularIntCommand(payload, 'timezoneMinutes'); break;
case 0xC7: data = parseSystemTime(payload); break;
case 0xC8: data = parseInitialBaudrate(payload); break;
case 0xC9: data = parseRegularUIntCommand(payload); break;
case 0xCA: data = parseRegularUIntCommand(payload, 'displayDecimalPlaces'); break;
case 0xCB: data = parseRegularUIntCommand(payload, 'extendedDisplayEnable'); break;
case 0xCE: data = parseRegularUIntCommand(payload, 'IECPasswordEnable'); break;
default:
data = { error: "No parser for command byte: 0x" + cmd.toString(16).toUpperCase() };
}
return {
direction: msgType,
imsi: meta.imsi,
customID: meta.customID,
commandByteName: meta.commandByteName,
commandByteHex: meta.commandByteHex,
data: data,
hex: convertArrayToHex(binData).toUpperCase()
};
// Nested helpers and parsers
function readCustomID(array) {
var maxLen = 64, imsiLen = 15, maxCheck = imsiLen + maxLen;
var info = { imsi: null, customID: null, commandByte: null, commandByteHex: null, commandByteName: null, offset: 0 };
var isIMSI = true;
for (var i = 0; i < array.length && i <= maxCheck; i++) {
var b = readUInt8(array, i);
var commandBytesIndex = "0x" + b.toString(16).toUpperCase();
if (commandByteNames[commandBytesIndex] || commandByteNames[b]) {
if(commandByteNames[b])
{
info.commandByteName = commandByteNames[b];
}
else
{
info.commandByteName = commandByteNames[commandBytesIndex];
}
info.commandByte = b;
info.commandByteHex = commandBytesIndex;
info.offset = i+1;
if ((i > imsiLen && isIMSI) || i === maxCheck) {
info.imsi = bufferToAsciiString(array,0,imsiLen).string;
info.customID = bufferToAsciiString(array,imsiLen,i).string;
} else {
info.customID = bufferToAsciiString(array,0,i).string;
}
return info;
} else if (i === array.length-1) {
throw new Error("No command byte found in the message.");
}
if (i < imsiLen && (b < 0x30 || b > 0x39)) isIMSI = false;
else if (i===imsiLen && (b>=0x30 && b<=0x39)) isIMSI = false;
}
}
function convertTimestamp(ts) {
var epoch = 1199145600;
var d = new Date((ts + epoch) * 1000);
function pad(n){ return n<10? '0'+n : n; }
return d.getUTCFullYear() + "-" + pad(d.getUTCMonth()+1) + "-" + pad(d.getUTCDate()) +
"T" + pad(d.getUTCHours()) + ":" + pad(d.getUTCMinutes()) + ":" + pad(d.getUTCSeconds());
}
function readSamples(bin, off) {
var reps = [];
for (var i=0; i<128; i++) {
if (off+4 <= bin.length) {
var t = readUInt32LE(bin, off); off+=4;
var v = readUInt32LE(bin, off); off+=4;
reps.push({ timestamp: t, timestampISO: convertTimestamp(t), value: v });
} else break;
}
return reps;
}
function parseSamples(bin, startOffset, startTimestamp, periodSeconds, maxSamples) {
var samples = [], repOff = startOffset, ts = startTimestamp, count = maxSamples || 128;
for (var i = 0; i < count; i++) {
if (repOff + 4 <= bin.length) {
var val = readUInt32LE(bin, repOff);
samples.push({ timestampISO: convertTimestamp(ts), value: val });
ts += periodSeconds;
repOff += 4;
} else break;
}
return samples;
}
function parsePeriodicReport(data) {
return {
messageSequenceNumber: readUInt32LE(data,0),
ratioValue: readInt32LE(data,4),
ratio: ratios[readInt32LE(data, 4).toString()],
batteryVoltageCPU: readUInt16LE(data,8),
signal: readUInt8(data,10),
temperatureCPU: readUInt8(data,11),
minFlowRateTimestamp: readUInt32LE(data,12),
minFlowRateTimestampISO: convertTimestamp(readUInt32LE(data,12)),
minFlowRate: readBigUInt64LE(data,16),
maxFlowRateTimestamp: readUInt32LE(data,24),
maxFlowRateTimestampISO: convertTimestamp(readUInt32LE(data,24)),
maxFlowRate: readBigUInt64LE(data,28),
samples: readSamples(data,36)
};
}
function parsePeriodicStatusReport(data) {
var mid = bufferToAsciiString(data,21,37);
var off = mid.offset;
var obj = {
messageSequenceNumber: readUInt32LE(data,0),
ratioValue: readInt32LE(data,4),
ratio: ratios[readInt32LE(data, 4).toString()],
batteryVoltageCPU: readUInt16LE(data,8),
signal: readUInt8(data,10),
temperatureCPU: readUInt8(data,11),
batteryCapacity: readUInt16LE(data,12),
totalConsumedEnergy: readUInt16LE(data,14),
seriesResistance: readUInt16LE(data,16),
inputVoltageLoad: readUInt16LE(data,18),
degreeCelsiusCC: readUInt8(data,20),
meterID: mid.string,
historyPeriodLength: readUInt8(data,off),
sendTimestamp: readUInt32LE(data,off+1),
sendTimestampISO: convertTimestamp(readUInt32LE(data,off+1)),
minFlowRateTimestamp: readUInt32LE(data,off+5),
minFlowRateTimestampISO: convertTimestamp(readUInt32LE(data,off+5)),
minFlowRate: readBigUInt64LE(data,off+9),
maxFlowRateTimestamp: readUInt32LE(data,off+17),
maxFlowRateTimestampISO: convertTimestamp(readUInt32LE(data,off+17)),
maxFlowRate: readBigUInt64LE(data,off+21),
samplingPeriodSeconds: readUInt32LE(data,off+29),
firstSampleTimestamp: readUInt32LE(data,off+33),
samples: parseSamples(data,off+37,readUInt32LE(data,off+33),readUInt32LE(data,off+29))
};
return obj;
}
function parsePeriodicStatusReportSN(data) {
var mid = bufferToAsciiString(data,30,46);
var off = mid.offset;
var obj = {
headerLength: readUInt16LE(data,0),
firmwareVersion: readUInt8(data,2)+'.'+readUInt8(data,3)+'.'+readUInt8(data,4),
serialNumber: readUInt32LE(data,5),
messageSequenceNumber: readUInt32LE(data,9),
ratioValue: readInt32LE(data,13),
ratio: ratios[readInt32LE(data, 13).toString()],
batteryVoltageCPU: readUInt16LE(data,17),
signal: readUInt8(data,19),
temperatureCPU: readUInt8(data,20),
batteryCapacity: readUInt16LE(data,21),
totalConsumedEnergy: readUInt16LE(data,23),
seriesResistance: readUInt16LE(data,25),
inputVoltageLoad: readUInt16LE(data,27),
degreeCelsiusCC: readUInt8(data,29),
meterID: mid.string,
historyPeriodLength: readUInt8(data,off),
sendTimestamp: readUInt32LE(data,off+1),
sendTimestampISO: convertTimestamp(readUInt32LE(data,off+1)),
minFlowRateTimestamp: readUInt32LE(data,off+5),
minFlowRateTimestampISO: convertTimestamp(readUInt32LE(data,off+5)),
minFlowRate: readBigUInt64LE(data,off+9),
maxFlowRateTimestamp: readUInt32LE(data,off+17),
maxFlowRateTimestampISO: convertTimestamp(readUInt32LE(data,off+17)),
maxFlowRate: readBigUInt64LE(data,off+21),
samplingPeriodSeconds: readUInt32LE(data,off+29),
firstSampleTimestamp: readUInt32LE(data,off+33),
samples: parseSamples(data,off+37,readUInt32LE(data,off+33),readUInt32LE(data,off+29))
};
return obj;
}
function parseReadArchive(data) {
var seq = readUInt32LE(data,0);
var rv = readInt32LE(data,4);
var start = readUInt32LE(data,8);
var period = readUInt32LE(data,12);
var arr = [];
var off = 16;
while (off+4 <= data.length) {
var val = readUInt32LE(data,off);
if (val === 0xFFFFFFFE) break;
arr.push({ timestampISO: convertTimestamp(start), value: val });
start += period;
off += 4;
}
return { messageSequenceNumber: seq, ratioValue: rv, archiveStart: readUInt32LE(data,8), archiveStartISO: convertTimestamp(readUInt32LE(data,8)), samplingPeriodSeconds: period, archiveSamples: [{ firstSampleTimestamp: readUInt32LE(data,8), samples: arr }] };
}
function parseDeviceInfo(data) {
return { messageSequenceNumber: readUInt32LE(data,0), deviceInfoLength: readUInt32LE(data,4), deviceInfo: bufferToAsciiString(data,8,data.length).string };
}
function parseRatio(data) { return { messageSequenceNumber: readUInt32LE(data,0), ratioValue: readInt32LE(data,4) } }
function parseSignalTester(data) { return { messageSequenceNumber:readUInt32LE(data,0), ratioValue:readInt32LE(data,4), batteryVoltageCPU:readUInt16LE(data,8), signal:readUInt8(data,10), temperatureCPU:readUInt8(data,11), pulses:readUInt32LE(data,12) } }
function parseSignalTesterCC(data) { return { messageSequenceNumber:readUInt32LE(data,0), ratioValue:readInt32LE(data,4), batteryVoltageCPU:readUInt16LE(data,8), signal:readUInt8(data,10), temperatureCPU:readUInt8(data,11), totalConsumedEnergy:readUInt16LE(data,12), seriesResistance:readUInt16LE(data,14), inputVoltageLoad:readUInt16LE(data,16), degreeCelsiusCC:readUInt8(data,18), pulses:readUInt32LE(data,19) } }
function parseSystemTime(data) { return { messageSequenceNumber:readUInt32LE(data,0), systemTime:readUInt32LE(data,4), systemTimeISO:convertTimestamp(readUInt32LE(data,4)) } }
function parseInitialBaudrate(data) { var v=readUInt32LE(data,4); return { messageSequenceNumber:readUInt32LE(data,0), initialBaudrate:v, initialBaudrateBauds:(v===0?300:9600) } }
function parseConfig(data) { var o=0; var m=readUInt32LE(data,o); o+=4; var len=readUInt32LE(data,o); o+=4; var apn=bufferToAsciiString(data,o,o+64); o=apn.offset; var ip=bufferToAsciiString(data,o,o+64); o=ip.offset; var port=readUInt32LE(data,o); o+=4; var plmn=readUInt32LE(data,o); o+=4; var cid=bufferToAsciiString(data,o,o+64); o=cid.offset; var rv2=readInt32LE(data,o); o+=4; var rstr=bufferToAsciiString(data,o,o+16); o=rstr.offset; var obc=bufferToAsciiString(data,o,o+16); o=obc.offset; var mid=bufferToAsciiString(data,o,o+16); o=mid.offset; var ssd=readUInt32LE(data,o); o+=4; var ssdsp=readUInt32LE(data,o); o+=4; var dct=readUInt32LE(data,o); o+=4; var ddt=readUInt32LE(data,o); o+=4; var mdp=readUInt32LE(data,o); o+=4; var sps=readUInt32LE(data,o); o+=4; var bc=readUInt16LE(data,o); o+=2; var hpl=readUInt8(data,o); o+=1; var ep=bufferToAsciiString(data,o,o+64); o=ep.offset; var su=bufferToAsciiString(data,o,o+64); o=su.offset; var srvp=readUInt32LE(data,o); o+=4; var slp=readUInt32LE(data,o); o+=4; var slt=readUInt32LE(data,o); o+=4; var pskid=bufferToAsciiString(data,o,o+64); o=pskid.offset; var psk=bufferToAsciiString(data,o,o+64); o=psk.offset; var stp=readUInt32LE(data,o); o+=4; var stm=readUInt32LE(data,o); o+=4; var stpl=readUInt32LE(data,o); o+=4; var tz=readInt32LE(data,o); o+=4; var ib=readUInt8(data,o); o+=1; var dp=readUInt8(data,o); o+=1; var ede=readUInt8(data,o); o+=1; var ipe=readUInt8(data,o); o+=1;
return { messageSequenceNumber:m, configPayloadLength:len, customAPN:apn.string, customIP:ip.string, customPort:port, customPLMNID:plmn, customID:cid.string, ratioValue:rv2, ratio:ratios[rv2.toString()], mode:readUInt32LE(data,o), unitStr:rstr.string, obisCode:obc.string, meterID:mid.string, sendSecondOfDay:ssd, sendSecondOfDaySpread:ssdsp, displayCountTime:dct, displayDateTime:ddt, maximumDetectorPeriod:mdp, samplingPeriodSeconds:sps, batteryCapacity:bc, historyPeriodLen:hpl, LWM2MEndpoint:ep.string, LWM2MServerURL:su.string, LWM2MServerPort:srvp, LWM2MLocalPort:slp, LWM2MLifetime:slt, LWM2MPSKID:pskid.string, LWM2MPSK:psk.string, signalTesterPeriod:stp, signalTesterMode:stm, signalTesterPayloadLen:stpl, timezoneMinutes:tz, initialBaudrate:ib, decimalPlaces:dp, extendedDisplayEnable:ede, IECPasswordEnable:ipe };
}
function parseRegularUIntCommand(data, propName) { var r={ messageSequenceNumber:readUInt32LE(data,0) }; if(propName) r[propName]=readUInt32LE(data,4); return r; }
function parseRegularIntCommand(data, propName) { var r={ messageSequenceNumber:readUInt32LE(data,0) }; if(propName) r[propName]=readInt32LE(data,4); return r; }
function parseRegularStringCommand(data, propName) { var r={ messageSequenceNumber:readUInt32LE(data,0) }; if(propName) r[propName]=bufferToAsciiString(data,4,data.length).string; return r; }
}
// Downlink parser
function parseDownlink(binData) {
var txt = '';
for (var i=0; i<binData.length; i++) {
var b=binData[i]; txt += (b>=32 && b<=126) ? String.fromCharCode(b) : ' ';
}
return { direction: msgType, asciiCommand: txt, hex: convertArrayToHex(binData).toUpperCase() };
}
}
function cesqToRssi(cesq) {
if (typeof cesq !== 'number' || cesq < 0 || cesq > 30) {
return -120; // Invalid input
}
// According to spec: CESQ 0 = -113 dBm, each step = +2 dBm
return -113 + (cesq * 2);
}
function Decoder(request) {
/*
This decoder expects JSON data in the format given below.
Upon receiving the data, it decodes it and forwards it to the database.
- To simulate, simply copy the following JSON and paste into the "Body" text field below.
- Click on "Try Decoder" to see the result.
{
"device":"REPLACE WITH SERIAL NUMBER",
"temperature":23.34,
"battery":3.02,
"humidity":56,
"co2":506,
"location":"(53.4562,6.99349)",
"power":true,
"energy":39495,
"solar":true,
"state":"System OK",
"counter":9349
}
*/
// First, parse the request body into a JSON object to process it
var payload = JSON.parse(request.body)
// Base64 characters set
var base64chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/';
function base64ToHex(str) {
var result = '';
var bits, h1, h2, h3, h4, b1, b2, b3;
var i = 0;
str = str.replace(/=+$/, '');
while (i < str.length) {
h1 = base64chars.indexOf(str.charAt(i++));
h2 = base64chars.indexOf(str.charAt(i++));
h3 = base64chars.indexOf(str.charAt(i++));
h4 = base64chars.indexOf(str.charAt(i++));
bits = (h1 << 18) | (h2 << 12) | ((h3 & 63) << 6) | (h4 & 63);
b1 = (bits >> 16) & 255;
b2 = (bits >> 8) & 255;
b3 = bits & 255;
if (h3 === -1) {
result += ('0' + b1.toString(16)).slice(-2);
} else if (h4 === -1) {
result += ('0' + b1.toString(16)).slice(-2);
result += ('0' + b2.toString(16)).slice(-2);
} else {
result += ('0' + b1.toString(16)).slice(-2);
result += ('0' + b2.toString(16)).slice(-2);
result += ('0' + b3.toString(16)).slice(-2);
}
}
return result;
}
// Usage:
var base64Str = payload["payload"];
var hexStr = base64ToHex(base64Str);
parsed = parseExPayload(hexStr, "uplink");
// Initialize the result array for Datacake
var result = [];
// Get current time for timestamp and derived fields
var t = new Date();
var timestamp = Math.floor(new Date(parsed["data"]["sendTimestampISO"]).getTime() / 1000);
// Device ID (use customID from parsed data)
var deviceId = parsed["data"]["serialNumber"] || "unknown";
var adjustmentFactor = 1; // Default: no adjustment
var operation = "none";
// 1. Add samples as individual data points under a single field with ratio adjustment
if (parsed["data"] && parsed["data"]["samples"] && Array.isArray(parsed["data"]["samples"])) {
// Determine the adjustment factor and operation from ratioValue
if (parsed["data"]["ratioValue"] !== undefined && parsed["data"]["ratioValue"] !== 0) {
adjustmentFactor = Math.abs(parsed["data"]["ratioValue"]); // Use absolute value
operation = parsed["data"]["ratioValue"] < 0 ? "divide" : "multiply";
console.log("Decoder: Applying " + operation + " by factor = " + adjustmentFactor + " based on ratioValue = " + parsed["data"]["ratioValue"]);
} else {
console.log("Decoder: No valid ratioValue found, no adjustment applied");
}
var firstRun = true;
var lastValue = 0;
var dailySamples = []; // Array to store samples for the current day
var currentDayKey = null;
var dailyResults = []; // Store daily differences to add after processing
// Sort samples by timestamp to ensure chronological order
var sortedSamples = parsed["data"]["samples"].slice().sort(function(a, b) {
return new Date(a.timestampISO).getTime() - new Date(b.timestampISO).getTime();
});
sortedSamples.forEach(function(sample) {
// Convert timestampISO to Unix timestamp (seconds)
var sampleTimestamp = new Date(sample.timestampISO).getTime() / 1000;
if (isNaN(sampleTimestamp)) {
console.log("Invalid timestampISO: " + sample.timestampISO);
sampleTimestamp = timestamp; // Fallback to current timestamp
} else {
sampleTimestamp = Math.floor(sampleTimestamp);
}
// Adjust the sample value based on ratioValue
var adjustedValue = sample.value;
if (operation === "divide") {
adjustedValue = sample.value / adjustmentFactor;
} else if (operation === "multiply") {
adjustedValue = sample.value * adjustmentFactor;
}
// Round to 2 decimal places for consistency
adjustedValue = Number(adjustedValue.toFixed(2));
// Determine the date key for the sample
var sampleDate = new Date(sampleTimestamp * 1000);
var dateKey = sampleDate.getFullYear() + '-' +
(sampleDate.getMonth() + 1) + '-' +
sampleDate.getDate();
// Check if we've crossed a day boundary
if (currentDayKey !== null && dateKey !== currentDayKey) {
// Process the previous day's samples
if (dailySamples.length === 24) { // Only process if exactly 24 samples
var firstSample = dailySamples[0];
var lastSample = dailySamples[dailySamples.length - 1];
var dailyDiff = Number((lastSample.value - firstSample.value).toFixed(2));
// Set timestamp to 12:00 of the day
var dateParts = currentDayKey.split('-');
var dailyTimestamp = new Date(
parseInt(dateParts[0]), // Year
parseInt(dateParts[1]) - 1, // Month (0-based)
parseInt(dateParts[2]), // Day
12, // Hour
0, // Minute
0 // Second
).getTime() / 1000;
dailyResults.push({
device: deviceId,
field: "GAS_CONSUMPTION_DIFF_DAILY",
value: dailyDiff,
timestamp: Math.floor(dailyTimestamp)
});
}
// Reset for the new day
dailySamples = [];
}
// Add the sample to the current day's samples
dailySamples.push({
value: adjustedValue,
timestamp: sampleTimestamp
});
currentDayKey = dateKey;
// Existing logic for GAS_CONSUMPTION and GAS_CONSUMPTION_DIFF
if (firstRun) {
firstRun = false;
lastValue = adjustedValue;
} else {
result.push({
device: deviceId,
field: "GAS_CONSUMPTION_DIFF",
value: Number((adjustedValue - lastValue).toFixed(2)),
timestamp: sampleTimestamp
});
lastValue = adjustedValue;
}
result.push({
device: deviceId,
field: "GAS_CONSUMPTION",
value: adjustedValue,
timestamp: sampleTimestamp
});
});
// Process the last day's samples
if (dailySamples.length === 24) { // Only process if exactly 24 samples
var firstSample = dailySamples[0];
var lastSample = dailySamples[dailySamples.length - 1];
var dailyDiff = Number((lastSample.value - firstSample.value).toFixed(2));
// Set timestamp to 12:00 of the day
var dateParts = currentDayKey.split('-');
var dailyTimestamp = new Date(
parseInt(dateParts[0]), // Year
parseInt(dateParts[1]) - 1, // Month (0-based)
parseInt(dateParts[2]), // Day
12, // Hour
0, // Minute
0 // Second
).getTime() / 1000;
dailyResults.push({
device: deviceId,
field: "GAS_CONSUMPTION_DIFF_DAILY",
value: dailyDiff,
timestamp: Math.floor(dailyTimestamp)
});
}
// Append daily results to the main result array
for (var i = 0; i < dailyResults.length; i++) {
result.push(dailyResults[i]);
}
} else {
console.log("No valid samples found in parsed data");
}
// convert max/min flow rates to m3/hour
maxFlowm3ph = ((parsed["data"]["maxFlowRate"]/1000000)*3600)
if (operation === "divide") {
maxFlowm3ph = maxFlowm3ph / adjustmentFactor;
} else if (operation === "multiply") {
maxFlowm3ph = maxFlowm3ph * adjustmentFactor;
}
// Convert timestampISO to Unix timestamp (seconds)
var maxFlowTimestamp = new Date(parsed["data"]["maxFlowRateTimestampISO"]).getTime() / 1000;
if (isNaN(maxFlowTimestamp)) {
console.log("Invalid timestampISO: " + parsed["data"]["maxFlowRateTimestampISO"]);
maxFlowTimestamp = timestamp; // Fallback to current timestamp
} else {
maxFlowTimestamp = Math.floor(maxFlowTimestamp);
}
minFlowm3ph = ((parsed["data"]["minFlowRate"]/1000000)*3600)
if (operation === "divide") {
minFlowm3ph = minFlowm3ph / adjustmentFactor;
} else if (operation === "multiply") {
minFlowm3ph = minFlowm3ph * adjustmentFactor;
}
// Convert timestampISO to Unix timestamp (seconds)
var minFlowTimestamp = new Date(parsed["data"]["minFlowRateTimestampISO"]).getTime() / 1000;
if (isNaN(minFlowTimestamp)) {
console.log("Invalid timestampISO: " + parsed["data"]["minFlowRateTimestampISO"]);
minFlowTimestamp = timestamp; // Fallback to current timestamp
} else {
minFlowTimestamp = Math.floor(minFlowTimestamp);
}
// 2. Add time-derived fields as data points
result.push({
device: deviceId,
field: "DAY_HOUR",
value: t.getHours(),
timestamp: timestamp
});
result.push({
device: deviceId,
field: "DAY_MINUTE",
value: t.getMinutes(),
timestamp: timestamp
});
result.push({
device: deviceId,
field: "DAY_SECOND",
value: t.getSeconds(),
timestamp: timestamp
});
result.push({
device: deviceId,
field: "SEC_OF_DAY",
value: t.getHours() * 3600 + t.getMinutes() * 60 + t.getSeconds(),
timestamp: timestamp
});
// 3. Add other fields from parsed["data"] (e.g., batteryVoltageCPU, temperatureCPU)
result.push({
device: deviceId,
field: "DEVICE_SIGNAL",
value: cesqToRssi(parsed["data"]["signal"]),
timestamp: timestamp
});
result.push({
device: deviceId,
field: "MIN_FLOW_RATE",
value: minFlowm3ph,
timestamp: minFlowTimestamp
});
result.push({
device: deviceId,
field: "MAX_FLOW_RATE",
value: maxFlowm3ph,
timestamp: maxFlowTimestamp
});
result.push({
device: deviceId,
field: "DEVICE_TOTAL_CONSUMED_ENERGY",
value: parsed["data"]["totalConsumedEnergy"],
timestamp: timestamp
});
result.push({
device: deviceId,
field: "DEVICE_REMAINING_BATTERY",
value: 100-(100*parsed["data"]["totalConsumedEnergy"]/8500),
timestamp: timestamp
});
result.push({
device: deviceId,
field: "BATTERY_VOLTAGE_UNDER_LOAD",
value: parsed["data"]["inputVoltageLoad"],
timestamp: timestamp
});
if(parsed["data"]["degreeCelsiusCC"] != 255) // 255 is invalid reading
{
result.push({
device: deviceId,
field: "DEVICE_TEMPERATURE",
value: parsed["data"]["degreeCelsiusCC"],
timestamp: timestamp
});
}
// Log the result for debugging
console.log("Decoder: Returning " + result.length + " data points");
console.log(JSON.stringify(result, null, 2));
//console.log(JSON.stringify(parsed, null, 2));
return result;
}
Step E - Connecting Datacake to the Device
- Connect the Datacake to the device by copying its endpoint and setting it up with a service, that the device sends the data with.
The endpoint URL can be found right under the Decoder.
Here you can find a guide how to set it up in a Miotiq service.
Miotiq setup guide
- Log in to your Miotiq account
- Click on Endpoints in the left side menu
- Click on Add group and once added
- click on the Endpoint settings of said group
- Create a HTTP Callback or/and edit it by clicking on the pen symbol
- Within the edit window, insert the link into URL address from STEP E
- Click on Save.
- Finally, you may click on Devices and info to see the details.
Step F - Selecting the Fields You Want to See Visualized
- Copy the following telegram code.
{"customerId":"05082251","rcvTime":1747198802,"srcImsi":"901288910238478","srcIP":"10.0.108.8","srcPort":"4242","payload":"ODYzMjY2MDUyMTIzNTIxqD4AAQEIx0MAABIAAACc////NQ0KCDQhNgD5AM8NBAAD4a6qIOCuqiAAAAAAAAAAADjQqSBFqQAAAAAAABAOAADwdqUgBasKAAqrCgAVqwoAOqsKADqrCgBCqwoAQqsKAEKrCgBCqwoAQqsKAEKrCgBCqwoAQqsKAFGrCgB1qwoAgqsKAIqrCgCKqwoAiqsKAIqrCgCKqwoAiqsKAIqrCgCKqwoAiqsKAJKrCgCSqwoAmKsKAJurCgCsqwoAtasKALyrCgDHqwoAx6sKAM6rCgDOqwoAzqsKAN2rCgDiqwoA8KsKAPCrCgDwqwoA8KsKAPCrCgDwqwoA8KsKAPerCgD3qwoA96sKAPerCgAHrAoAB6wKAA2sCgAerAoAKKwKACisCgAzrAoAM6wKADOsCgA1rAoAN6wKAD+sCgA/rAoAUKwKAFCsCgBQrAoAUKwKAFCsCgBQrAoAUKwKAFasCgBWrAoAZqwKAGysCgBsrAoAc6wKAHmsCgB5rAoAe6wKAH6sCgCSrAoAnqwKAJ6sCgCerAoAsKwKALCsCgC+rAoAvqwKAL6sCgC+rAoAvqwKAL6sCgC+rAoAvqwKAL6sCgDFrAoA"}
- Insert the text into the Body field
- Now click on Try Decoder button
- If all is done accordingly, the following should show up. Now click on the individual items to add them - red means they have not been added yet
- Fill in all relevant fields - a lot of them are only informative - feel free to fill them according to your needs
- Click on Add Field
- You should see the added fields underneath, you can further edit them by clicking on three dots
Example
Here you can see our configuration, feel free to use the same exact configuration!
Make sure to keep an eye on propper data TYPE, ROLE and to use appropriate units in VALUE
Handcrafting your Visualization
And finally, you may customize your visualization according to your needs, such us we did.
Feel free to checkout live board HERE.
Conclusion
Datacake is a powerfull platform to visualize data, making it an ideal tool to integrate with ACR-EX Pulse Converter Device as it collects samples at 15 minute intervals and sends them once a day.