diff --git a/lib/payload.json b/lib/payload.json index 7971c43742..1428bb863c 100644 --- a/lib/payload.json +++ b/lib/payload.json @@ -154,10 +154,45 @@ "leak": { "type": ["boolean", "string"], "description": "Leak detected" + }, + "temperature": { + "type": "object", + "properties": { + "min": { + "description": "Minimum temperature (°C)", + "$ref": "#/definitions/temperature" + }, + "max": { + "description": "Maximum temperature (°C)", + "$ref": "#/definitions/temperature" + }, + "avg": { + "description": "Average temperature (°C)", + "$ref": "#/definitions/temperature" + }, + "current": { + "description": "Current temperature (°C)", + "$ref": "#/definitions/temperature" + } + } } }, "additionalProperties": false }, + "metering": { + "type": "object", + "properties": { + "water": { + "type": "object", + "properties": { + "total": { + "type": "number", + "description": "Total volume (L)" + } + } + } + } + }, "action": { "type": "object", "properties": { diff --git a/vendor/quandify/cubicmeter-1-1-plastic-codec.yaml b/vendor/quandify/cubicmeter-1-1-plastic-codec.yaml index 9980bdacb6..0bf116efa3 100644 --- a/vendor/quandify/cubicmeter-1-1-plastic-codec.yaml +++ b/vendor/quandify/cubicmeter-1-1-plastic-codec.yaml @@ -2,3 +2,84 @@ # For documentation on writing encoders and decoders, see: https://thethingsstack.io/integrations/payload-formatters/javascript/ uplinkDecoder: fileName: cubicmeter-1-1-uplink.js + + # Examples + examples: + - description: Normal status report + input: + fPort: 1 + bytes: [73, 251, 223, 1, 0, 0, 222, 20, 0, 0, 0, 0, 0, 0, 70, 237, 223, 1, 6, 252, 139, 7, 2, 226, 230, 83, 84, 85] + output: + data: + fPort: 1 + length: 28 + hexBytes: '49FBDF010000DE1400000000000046EDDF0106FC8B0702E2E6535455' + type: 'statusReport' + decoded: + ambientTemperature: 22.5 + batteryActive: 3608 + batteryRecovered: 3640 + errorCode: 0 + isSensing: true + leakState: 2 + totalVolume: 5342 + waterTemperatureMax: 22 + waterTemperatureMin: 21.5 + warnings: [] + errors: [] + normalizedOutput: + data: + - air: + temperature: 22.5 + water: + temperature: + min: 21.5 + max: 22 + leak: '' + metering: + water: + total: 5342 + battery: 3.64 + warnings: [] + errors: [] + - description: Response from set pipe downlink + input: + fPort: 6 + bytes: [4, 0, 2, 16, 0, 0, 22, 2, 5, 63, 1, 0, 0, 3, 0, 227, 221, 203, 177, 189, 117, 135, 50, 0, 0, 1, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0] + output: + data: + fPort: 6 + length: 38 + hexBytes: '0400021000001602053F0100000300E3DDCBB1BD758732000001000000000000010000000000' + type: 'response' + decoded: + fPort: 4 + status: 'ok' + type: 'hardwareReport' + data: + appState: 'metering' + firmwareVersion: '22.0.16' + hardwareVersion: 2 + pipe: + id: 0 + type: 'Custom' + warnings: [] + errors: [] + - description: Response from set lorawan report interval + input: + fPort: 6 + bytes: [19, 0, 4, 1, 192, 168, 0, 0, 88, 2, 0, 0, 0, 0, 0, 30, 0, 0, 141, 39, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0] + output: + data: + fPort: 6 + length: 41 + hexBytes: '13000401C0A80000580200000000001E00008D27000000000000000000000000000000000000000000' + type: 'response' + decoded: + fPort: 19 + status: 'ok' + type: 'settingsReport' + data: + lorawanReportInterval: 600 + warnings: [] + errors: [] diff --git a/vendor/quandify/cubicmeter-1-1-uplink.js b/vendor/quandify/cubicmeter-1-1-uplink.js index e933ccb4c2..663064ed71 100644 --- a/vendor/quandify/cubicmeter-1-1-uplink.js +++ b/vendor/quandify/cubicmeter-1-1-uplink.js @@ -1,113 +1,258 @@ -// Please read here on how to implement the proper codec: https://www.thethingsindustries.com/docs/integrations/payload-formatters/javascript/ - // Cubicmeter 1.1 uplink decoder +var appStates = { + 3: 'ready', + 4: 'pipeSelection', + 5: 'metering', +}; + +var uplinkTypes = { + 0: 'ping', + 1: 'statusReport', + 6: 'response', +}; + +var responseStatuses = { + 0: 'ok', + 1: 'commandError', + 2: 'payloadError', + 3: 'valueError', +}; + +// More uplink types only available when using Quandify platform API +var responseTypes = { + 0: 'none', + 1: 'statusReport', + 2: 'hardwareReport', + 4: 'settingsReport', +}; + +/* Smaller water leakages only availble when using Quandify platform API +as it requires cloud analytics */ +var leakStates = { + 2: 'medium', + 3: 'large', +}; + +var pipeTypes = { + 0: 'Custom', + 1: 'Copper 15 mm', + 2: 'Copper 18 mm', + 3: 'Copper 22 mm', + 4: 'Chrome 15 mm', + 5: 'Chrome 18 mm', + 6: 'Chrome 22 mm', + 7: 'Pal 16 mm', + 8: 'Pal 20 mm', + 9: 'Pal 25 mm', + 14: 'Pex 16 mm', + 15: 'Pex 20 mm', + 16: 'Pex 25 mm', + 17: 'Distpipe', +}; + +/** + * 4.1 Uplink Decode + * The 'decodeUplink' function takes a message object and returns a parsed data object. + * @param input Message object + * @param input.fPort int, The uplink message LoRaWAN fPort. + * @param input.bytes int[], The uplink payload byte array, where each byte is represented by an integer between 0 and 255. + * @param input.recvTime Date, The uplink message timestamp recorded by the LoRaWAN network server as a JavaScript Date object. + */ function decodeUplink(input) { + const buffer = new ArrayBuffer(input.bytes.length); + const data = new DataView(buffer); + for (const index in input.bytes) { + data.setUint8(index, input.bytes[index]); + } + var decoded = {}; + var errors = []; + var warnings = []; - switch (input.fPort) { - case 1: // Status report - decoded = statusReportDecoder(input.bytes); - break; + try { + switch (input.fPort) { + case 1: // Status report + ({ decoded, warnings } = statusReportDecoder(data)); + break; + case 6: // Response + ({ decoded, warnings } = responseDecoder(data)); + break; + } + } catch (err) { + // Something went terribly wrong + errors.push(err.message); } return { data: { fPort: input.fPort, length: input.bytes.length, - hexBytes: toHexString(input.bytes), - type: getPacketType(input.fPort), - decoded: decoded, + hexBytes: decArrayToStr(input.bytes), + type: uplinkTypes[input.fPort], + decoded, }, + errors, + warnings, }; } -var LSB = true; -var statusReportDecoder = function (bytes) { - const buffer = new ArrayBuffer(bytes.length); - const data = new DataView(buffer); - if (bytes.length < 28) { - throw new Error('payload too short'); - } - for (const index in bytes) { - data.setUint8(index, bytes[index]); +const LSB = true; + +var statusReportDecoder = function (data) { + if (data.byteLength != 28) { + throw new Error(`Wrong payload length (${data.byteLength}), should be 28 bytes`); } - return { - errorCode: data.getUint16(4, LSB), // current error code + let warnings = []; + const error = data.getUint16(4, LSB); + + // The is sensing value is a bit flag of the error field + const isSensing = !(error & 0x8000); + const errorCode = error & 0x7fff; + + const decoded = { + errorCode: errorCode, // current error code + isSensing: isSensing, // is the ultrasonic sensor sensing water totalVolume: data.getUint32(6, LSB), // All-time aggregated water usage in litres - leakStatus: data.getUint8(22), // current water leakage state + leakState: data.getUint8(22), // current water leakage state batteryActive: decodeBatteryLevel(data.getUint8(23)), // battery mV active batteryRecovered: decodeBatteryLevel(data.getUint8(24)), // battery mV recovered waterTemperatureMin: decodeTemperature(data.getUint8(25)), // min water temperature since last statusReport waterTemperatureMax: decodeTemperature(data.getUint8(26)), // max water temperature since last statusReport ambientTemperature: decodeTemperature(data.getUint8(27)), // current ambient temperature }; + + // Warnings + if (decoded.isSensing === false) { + warnings.push('Not sensing water'); + } + + if (decoded.errorCode) { + warnings.push(parseErrorCode(decoded.errorCode)); + } + + if (isLowBattery(decoded.batteryRecovered)) { + warnings.push('Low battery'); + } + + return { + decoded, + warnings, + }; }; -function decodeBatteryLevel(input) { - return 1800 + (input << 3); // convert to milliVolt -} +var responseDecoder = function (data) { + const status = responseStatuses[data.getUint8(1)]; + if (status === undefined) { + throw new Error(`Invalid response status: ${data.getUint8(1)}`); + } -function parseBatteryStatus(input) { - if (input <= 3100) { - return 'Low battery'; + const type = responseTypes[data.getUint8(2)]; + if (type === undefined) { + throw new Error(`Invalid response type: ${data.getUint8(2)}`); } - return ''; -} + const payload = new DataView(data.buffer, 3); -function decodeTemperature(input) { - return input * 0.5 - 20.0; // to °C -} + var response = { + decoded: {}, + warnings: [], + }; -// More packet types only available when using Quandify platform API -var getPacketType = function (type) { switch (type) { - case 0: - return 'ping'; // empty ping message - case 1: - return 'statusReport'; // status message + case 'statusReport': + response = statusReportDecoder(payload); + break; + case 'hardwareReport': + response = hardwareReportDecoder(payload); + break; + case 'settingsReport': + response = settingsReportDecoder(payload); + break; } - return 'Unknown'; + return { + decoded: { + fPort: data.getUint8(0), + status: status, + type: type, + data: response.decoded, + }, + warnings: response.warnings, + }; }; -/* Smaller water leakages only availble when using Quandify platform API -as it requires cloud analytics */ -var parseLeakState = function (input) { - switch (input) { - case 3: - return 'Medium'; - case 4: - return 'Large'; - default: - return ''; +var hardwareReportDecoder = function (data) { + if (data.byteLength != 35) { + throw new Error(`Wrong payload length (${data.byteLength}), should be 35 bytes`); } + + const appState = appStates[data.getUint8(5)]; + if (appState === undefined) { + throw new Error(`Invalid app state (${data.getUint8(5)})`); + } + + const pipeType = pipeTypes[data.getUint8(28)]; + if (pipeType === undefined) { + throw new Error(`Invalid pipe index (${data.getUint8(28)})`); + } + + const firmwareVersion = intToSemver(data.getUint32(0, LSB)); + + return { + decoded: { + firmwareVersion, + hardwareVersion: data.getUint8(4), + appState: appState, + pipe: { + id: data.getUint8(28), + type: pipeType, + }, + }, + warnings: [], + }; }; -function toHexString(byteArray) { - return Array.from(byteArray, function (byte) { - return ('0' + (byte & 0xff).toString(16)).slice(-2).toUpperCase(); - }).join(''); -} +var settingsReportDecoder = function (data) { + if (data.byteLength != 38) { + throw new Error(`Wrong payload length (${data.byteLength}), should be 38 bytes`); + } + + return { + decoded: { + lorawanReportInterval: data.getUint32(5, LSB), + }, + warnings: [], + }; +}; + +var decodeBatteryLevel = function (input) { + return 1800 + (input << 3); // convert to milliVolt +}; + +var decodeTemperature = function (input) { + return parseFloat(input) * 0.5 - 20.0; // to °C +}; + +var isLowBattery = function (batteryRecovered) { + return batteryRecovered <= 3100; +}; -function parseErrorCode(errorCode) { +var parseErrorCode = function (errorCode) { switch (errorCode) { - case 0: - return ''; case 384: return 'Reverse flow'; - case 419: - case 421: - case 32768: - return 'No sensing'; default: - return 'Contact support'; + return `Contact support, error ${errorCode}`; + } +}; + +var normalizeUplink = function (input) { + if (input.data.type != 'statusReport') { + return {}; } -} -function normalizeUplink(input) { return { data: { air: { @@ -118,7 +263,7 @@ function normalizeUplink(input) { min: input.data.decoded.waterTemperatureMin, // °C max: input.data.decoded.waterTemperatureMax, // °C }, - leak: parseLeakState(input.data.decoded.leak_state), // String + leak: leakStates[input.data.decoded.leak_state] ? leakStates[input.data.decoded.leak_state] : '', // String }, metering: { water: { @@ -127,6 +272,40 @@ function normalizeUplink(input) { }, battery: input.data.decoded.batteryRecovered / 1000, // V }, - warnings: [parseErrorCode(input.data.decoded.errorCode), parseBatteryStatus(input.data.decoded.batteryRecovered)].filter((item) => item), + warnings: input.warnings, + errors: input.errors, }; -} +}; + +// Convert a hex string to decimal array +var hexToDecArray = function (hexString) { + const size = 2; + const length = Math.ceil(hexString.length / size); + const decimalList = new Array(length); + + for (let i = 0, o = 0; i < length; ++i, o += size) { + decimalList[i] = parseInt(hexString.substr(o, size), 16); + } + + return decimalList; +}; + +var base64ToDecArray = function (base64String) { + const buffer = Buffer.from(base64String, 'base64'); + const bufString = buffer.toString('hex'); + + return hexToDecArray(bufString); +}; + +var decArrayToStr = function (byteArray) { + return Array.from(byteArray, function (byte) { + return ('0' + (byte & 0xff).toString(16)).slice(-2).toUpperCase(); + }).join(''); +}; + +var intToSemver = function (version) { + const major = (version >> 24) & 0xff; + const minor = (version >> 16) & 0xff; + const patch = version & 0xffff; + return `${major}.${minor}.${patch}`; +};