Skip to content

Plugin file

Plugin configuration file
{
  "name": "kamstrup_flowiq2200_02k73a",
  "version": "1.0.0",
  "description": "Kamstrup flowIQ®2200 OMS over LoRaWAN water meter. Configuration no 02K73A******",
  "author": "Thinger.io",
  "license": "MIT",
  "repository": {
    "type": "git",
    "url": "https://github.com/thinger-io/plugins.git",
    "directory": "kamstrup-flowiq2200-02k73a"
  },
  "metadata": {
    "name": "Kamstrup FLOWIQ2200-02K73A",
    "description": "Kamstrup flowIQ®2200 OMS over LoRaWAN water meter. Configuration no 02K73A******",
    "image": "assets/flowiq2200-02k73a.png",
    "category": "devices",
    "vendor": "kamstrup"
  },
  "resources": {
    "products": [
      {
        "description": "Kamstrup flowIQ®2200 OMS over LoRaWAN water meter. Configuration no 02K73A******",
        "enabled": true,
        "name": "Kamstrup FLOWIQ2200-02K73A",
        "product": "kamstrup_flowiq2200_02k73a",
        "profile": {
          "api": {
            "downlink": {
              "enabled": true,
              "handle_connectivity": false,
              "request": {
                "data": {
                  "path": "/downlink",
                  "payload": "{\n    \"data\"    : \"{{payload.data=\"\"}}\",\n    \"port\"    :  {{payload.port=85}},\n    \"priority\":  {{payload.priority=3}},\n    \"confirmed\" :  {{payload.confirmed=false}},\n    \"uplink\"  :  {{property.uplink}} \n}",
                  "payload_function": "",
                  "payload_type": "",
                  "plugin": "{{property.uplink.source}}",
                  "target": "plugin_endpoint"
                }
              }
            },
            "uplink": {
              "enabled": true,
              "handle_connectivity": true,
              "request": {
                "data": {
                  "payload": "{{payload}}",
                  "payload_function": "",
                  "payload_type": "source_payload",
                  "resource_stream": "uplink",
                  "target": "resource_stream"
                }
              }
            }
          },
          "autoprovisions": {
            "device_autoprovisioning": {
              "config": {
                "mode": "pattern",
                "pattern": "kamstrup_flowiq2200_.*"
              },
              "enabled": true
            }
          },
          "buckets": {
            "kamstrup_flowiq2200_data": {
              "backend": "mongodb",
              "data": {
                "payload": "{{payload}}",
                "payload_function": "",
                "payload_type": "source_payload",
                "resource_stream": "uplink_decoded",
                "source": "resource_stream"
              },
              "enabled": true,
              "retention": {
                "period": 3,
                "unit": "months"
              },
              "tags": []
            }
          },
          "code": {
            "code": "function decodeThingerUplink(thingerData) {\n    // 0. If data has already been decoded, we will return it\n    if (thingerData.decodedPayload) return thingerData.decodedPayload;\n    \n    // 1. Extract and Validate Input\n    // We need 'payload' (hex string) and 'fPort' (integer)\n    const hexPayload = thingerData.payload || \"\";\n    const port = thingerData.fPort || 1;\n\n    // 2. Convert Hex String to Byte Array\n    const bytes = [];\n    for (let i = 0; i < hexPayload.length; i += 2) {\n        bytes.push(parseInt(hexPayload.substr(i, 2), 16));\n    }\n\n    // 3. Dynamic Function Detection and Execution\n    \n    // CASE A: (The Things Stack v3)\n    if (typeof decodeUplink === 'function') {\n        try {\n            const input = {\n                bytes: bytes,\n                fPort: port\n            };\n            var result = decodeUplink(input);\n            \n            if (result.data) return result.data;\n\n            return result; \n        } catch (e) {\n            console.error(\"Error inside decodeUplink:\", e);\n            throw e;\n        }\n    }\n\n    // CASE B: Legacy TTN (v2)\n    else if (typeof Decoder === 'function') {\n        try {\n            return Decoder(bytes, port);\n        } catch (e) {\n            console.error(\"Error inside Decoder:\", e);\n            throw e;\n        }\n    }\n\n    // CASE C: No decoder found\n    else {\n        throw new Error(\"No compatible TTN decoder function (decodeUplink or Decoder) found in scope.\");\n    }\n}\n\n\n// TTN decoder\n/**\n * THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED,\n * INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A\n * PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT\n * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION\n * OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE\n * SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.\n */\n\n// Primary VIF table according to Table 10 in 13757-3:2018\nvar PriVifTable = {\n    0x10: { type: \"Volume\", unit: \"m3\", resolution: 1E-6, ConversionType: \"B\" },\n    0x11: { type: \"Volume\", unit: \"m3\", resolution: 1E-5, ConversionType: \"B\" },\n    0x12: { type: \"Volume\", unit: \"m3\", resolution: 1E-4, ConversionType: \"B\" },\n    0x13: { type: \"Volume\", unit: \"m3\", resolution: 1E-3, ConversionType: \"B\" },\n    0x14: { type: \"Volume\", unit: \"m3\", resolution: 1E-2, ConversionType: \"B\" },\n    0x15: { type: \"Volume\", unit: \"m3\", resolution: 1E-1, ConversionType: \"B\" },\n    0x16: { type: \"Volume\", unit: \"m3\", resolution: 1E-0, ConversionType: \"B\" },\n    0x17: { type: \"Volume\", unit: \"m3\", resolution: 1E+1, ConversionType: \"B\" },\n    0x38: { type: \"Volume flow\", unit: \"m3/h\", resolution: 1E-6, ConversionType: \"B\" },\n    0x39: { type: \"Volume flow\", unit: \"m3/h\", resolution: 1E-5, ConversionType: \"B\" },\n    0x3A: { type: \"Volume flow\", unit: \"m3/h\", resolution: 1E-4, ConversionType: \"B\" },\n    0x3B: { type: \"Volume flow\", unit: \"m3/h\", resolution: 1E-3, ConversionType: \"B\" },\n    0x3C: { type: \"Volume flow\", unit: \"m3/h\", resolution: 1E-2, ConversionType: \"B\" },\n    0x3D: { type: \"Volume flow\", unit: \"m3/h\", resolution: 1E-1, ConversionType: \"B\" },\n    0x3E: { type: \"Volume flow\", unit: \"m3/h\", resolution: 1E-0, ConversionType: \"B\" },\n    0x3F: { type: \"Volume flow\", unit: \"m3/h\", resolution: 1E+1, ConversionType: \"B\" },\n    0x58: { type: \"Flow temperature\", unit: \"C\", resolution: 1E-3, ConversionType: \"B\" },\n    0x59: { type: \"Flow temperature\", unit: \"C\", resolution: 1E-2, ConversionType: \"B\" },\n    0x5A: { type: \"Flow temperature\", unit: \"C\", resolution: 1E-1, ConversionType: \"B\" },\n    0x5B: { type: \"Flow temperature\", unit: \"C\", resolution: 1E-0, ConversionType: \"B\" },\n    0x64: { type: \"External temperature\", unit: \"C\", resolution: 1E-3, ConversionType: \"B\" },\n    0x65: { type: \"External temperature\", unit: \"C\", resolution: 1E-2, ConversionType: \"B\" },\n    0x66: { type: \"External temperature\", unit: \"C\", resolution: 1E-1, ConversionType: \"B\" },\n    0x67: { type: \"External temperature\", unit: \"C\", resolution: 1E-0, ConversionType: \"B\" },\n    0x6C: { type: \"Date/time\", unit: \"NA\", resolution: 1, ConversionType: \"G\" },\n    0x6D: { type: \"Date/time\", unit: \"NA\", resolution: 1, ConversionType: \"F/J/I/M\" },\n};\n\n// Manufacture specific VIFE\nvar ManuVifeTable = {\n    0x25: { type: \"Infocode\", unit: \"NA\", resolution: 1, ConversionType: \"D\" },\n    0x1C: { type: \"ALD last day\", unit: \"NA\", resolution: 1, ConversionType: \"C\" },\n    0x16: { type: \"Module type/config number\", unit: \"NA\", resolution: 1, ConversionType: \"C\" },\n    0x1B: { type: \"ALD\", unit: \"NA\", resolution: 1, ConversionType: \"C\" },\n};\n\n// Orthogonal VIFE table according to Table 15 in 13757-3:2018\nvar OrthoVifeTable = {\n    0x13: \"Inverse Compact Profile\",\n    0x3C: \"Reverse\",\n};\n\nvar InfocodeTable = {\n    0: \"Dry\",\n    1: \"Reverse\",\n    2: \"Leak\",\n    3: \"Burst\",\n    4: \"Tamper\",\n    5: \"Low Battery\",\n    6: \"Low Ambient Temperature\",\n    7: \"High Ambient Temperature\",\n};\n\n/**\n * Parse Value Information Block (VIB)\n * @param VIBArray - Array containing the VIB\n * @returns - The VIB object\n */\nfunction parseVIB(VIBArray) {\n    var VIBObj = {};\n    if (VIBArray[0] == 0xFF) {\n        if (VIBArray[1] in ManuVifeTable) {\n            var tempObj = ManuVifeTable[VIBArray[1]];\n            VIBObj = { type: tempObj.type, unit: tempObj.unit,\n                resolution: tempObj.resolution, ConversionType: tempObj.ConversionType }; // Copy instead of reference\n            VIBObj.OrthoVife = \"NA\";\n        } else {\n            return null;\n        }\n    } else if ((VIBArray[0] & 0x7F) in PriVifTable) {\n        var tempObj = PriVifTable[VIBArray[0] & 0x7F];\n        VIBObj = { type: tempObj.type, unit: tempObj.unit,\n            resolution: tempObj.resolution, ConversionType: tempObj.ConversionType }; // Copy instead of reference\n        if ((VIBArray[0] & 0x80) != 0) {\n            if (VIBArray[1] in OrthoVifeTable) {\n                VIBObj.OrthoVife = OrthoVifeTable[VIBArray[1]];\n            } else {\n                return null;\n            }\n        } else {\n            VIBObj.OrthoVife = \"NA\";\n        }\n    } else {\n        // Unsupported VIF\n        return null;\n    }\n    VIBObj.isProfileData = false;\n    if (VIBObj.OrthoVife == \"Inverse Compact Profile\") {\n        VIBObj.isProfileData = true;\n    }\n    return VIBObj;\n}\n\n/**\n * Reads an unsigned little endian integer from a buffer.\n * @param buffer - The input buffer.\n * @param offset - The byte index to start reading from.\n * @param byteLength - The number of bytes to read.\n * @returns - The unsigned integer value.\n */\n function readUIntLE(buffer, offset, byteLength) {\n    var value = 0;\n    for (var i = 0; i < byteLength; i++) {\n        value |= buffer[offset + i] << (8 * i);\n    }\n    return value >>> 0; // Ensure it's treated as an unsigned 32-bit integer\n}\n\n/**\n * Reads a signed little-endian integer using two's complement representation.\n * @param buffer - The input buffer.\n * @param offset - The byte index to start reading from.\n * @param byteLength - The number of bytes to read.\n * @returns - The signed integer value.\n */\nfunction readIntLE(buffer, offset, byteLength) {\n    var value = readUIntLE(buffer, offset, byteLength);\n    var maxVal = 1 << (8 * byteLength - 1); // Two's complement sign bit position\n\n    return (value & maxVal) ? value - (1 << (8 * byteLength)) : value;\n}\n\n/**\n * Mbus Type A - BCD to a number as described in EN13757-2018 Annex A.\n * @param buffer - Buffer input containing the BCD in little endian.\n * @param idx - Index at which the BCD starts.\n * @param size - Size in bytes of the BCD.\n * @returns - The converted number or undefined if invalid.\n */\nfunction TypeA(buffer, idx, size) {\n    var result = 0;\n    var multiplier = 1;\n    for (var j = idx; j < idx + size; j++) {\n        var lsb = buffer[j] & 0xF;\n        var msb = (buffer[j] >> 4) & 0xF;\n        if (lsb > 9 || msb > 9) {\n            // Invalid value\n            return undefined;\n        }\n        result += lsb * multiplier;\n        result += msb * multiplier * 10;\n        multiplier *= 100;\n    }\n    return result;\n}\n\n/**\n * Mbus Type B - Binary signed integer as described in EN13757-2018 Annex A.\n * @param buffer - Buffer input containing value to convert in little endian.\n * @param idx - Index at which the value starts.\n * @param size - Size in bytes.\n * @returns - The converted number or undefined if invalid.\n */\nfunction TypeB(buffer, idx, size) {\n    var invalidValues = { 1: -0x80, 2: -0x8000, 3: -0x800000, 4: -0x80000000, 6: -0x800000000000 };\n    var result = readIntLE(buffer, idx, size);\n    if (result == invalidValues[size]) {\n        result = undefined;\n    }\n    return result;\n}\n\n/**\n * Mbus Type C - Binary unsigned integer as described in EN13757-2018 Annex A.\n * @param buffer - Buffer input containing value to convert in little endian.\n * @param idx - Index at which the value starts.\n * @param size - Size in bytes.\n * @returns - The converted number or undefined if invalid.\n */\nfunction TypeC(buffer, idx, size) {\n    var invalidValues = { 1: 0xFF, 2: 0xFFFF, 3: 0xFFFFFF, 4: 0xFFFFFFFF, 6: 0xFFFFFFFFFFFF };\n    var result = readUIntLE(buffer, idx, size);\n    if (result == invalidValues[size]) {\n        result = undefined;\n    }\n    return result;\n}\n\n/**\n * Mbus Type D - Boolean array as described in EN13757-2018 Annex A.\n * @param buffer - Buffer input containing value to convert in little endian.\n * @param idx - Index at which the value starts.\n * @param size - Size in bytes.\n * @returns - The converted number.\n */\nfunction TypeD(buffer, idx, size) {\n    return readUIntLE(buffer, idx, size);\n}\n\n/**\n * Mbus Type F - Date and Time (CP32) as described in EN13757-2018 Annex A.\n * @param buffer - Buffer input containing value to convert in little endian.\n * @param idx - Index at which the value starts.\n * @param size - Size in bytes.\n * @returns - Timestamp.\n */\nfunction TypeF(buffer, idx, size) {\n    var data = readUIntLE(buffer, idx, size);\n    if ((data & 0x00000080) != 0) {\n        return undefined;\n    }\n    var minutes = data & 0x0000003f;\n    var hours = (data >> 8) & 0x0000001f;\n    var days = (data >> 16) & 0x0000001f;\n    var months = (data >> 24) & 0x0000000f;\n    var years = (data >> 21) & 0x00000007;\n    years += ((data >> 28) & 0x0000000f) << 3;\n    var hYears = (data >> 13) & 0x00000003;\n    years += 1900 + (hYears * 100);\n    return new Date(Date.UTC(years, months - 1, days, hours, minutes));\n}\n\n/**\n * Mbus Type G - Date as described in EN13757-2018 Annex A.\n * @param buffer - Buffer input containing value to convert in little endian.\n * @param idx - Index at which the value starts.\n * @param size - Size in bytes.\n * @returns - Timestamp.\n */\nfunction TypeG(buffer, idx, size) {\n    var data = readUIntLE(buffer, idx, size);\n    if (data == 0xFFFF) {\n        return undefined;\n    }\n    var days = (data >> 0) & 0x001f;\n    var months = (data >> 8) & 0x000f;\n    var years = 2000 + ((data >> 5) & 0x0007);\n    years += ((data >> 12) & 0x000f) << 3;\n    return new Date(Date.UTC(years, months - 1, days));\n}\n\n/**\n * Mbus Type I - Date and Time (CP48) as described in EN13757-2018 Annex A.\n * @param buffer - Buffer input containing value to convert in little endian.\n * @param idx - Index at which the value starts.\n * @param size - Size in bytes.\n * @returns - Timestamp.\n */\n function TypeI(buffer, idx, size) {\n    if ((buffer[idx + 1] & 0x80) != 0) {\n        return undefined;\n    }\n    var seconds = buffer[idx] & 0x3f;\n    var minutes = buffer[idx + 1] & 0x3f;\n    var hours = buffer[idx + 2] & 0x1f;\n    var days = buffer[idx + 3] & 0x1f;\n    var months = buffer[idx + 4] & 0xf;\n    var years = (buffer[idx + 3] >> 5) & 0x7;\n    years += ((buffer[idx + 4] >> 4) & 0xf) << 3;\n    years += 2000;\n    return new Date(Date.UTC(years, months - 1, days, hours, minutes, seconds));\n}\n\n/**\n * Inverse Compact Profile - Parsing of Inverse Compact Profile as described in EN13757-2018 Annex F.\n * @param buffer - The input buffer.\n * @param idx - The index at which Inverse Compact Profile header starts (byte after LVAR).\n * @param size - The size of the inverse compact profile (equal to LVAR).\n * @returns - An object containing the profile data.\n */\nfunction InverseCompactProfile(buffer, idx, size) {\n    var result = {};\n    var spacingControl = buffer[idx];\n    idx++;\n    result.spacingValue = buffer[idx];\n    idx++;\n    var elementSize = spacingControl & 0x0F;\n    result.spacingUnit = (spacingControl >> 4) & 0x03;\n    result.incMode = (spacingControl >> 6) & 0x03;\n    result.profileValues = [];\n    if (elementSize < 1 || elementSize > 4 || result.incMode == 0) {\n        return null;\n    }\n    for (var k = 0; k < size - 2; k += elementSize) {\n        if (result.incMode == 0x3) { // Signed difference - TypeB\n            result.profileValues.push(TypeB(buffer, idx, elementSize));\n        } else { // Unsigned increments or decrements - TypeC\n            result.profileValues.push(TypeC(buffer, idx, elementSize));\n        }\n        idx += elementSize;\n    }\n    return result;\n}\n\n/**\n * GetNextTimestamp - Calculate next timestamp for delta values based on spacing unit/value as described in EN13757-2018 Annex F.\n * @param timestamp - The timestamp to operate on.\n * @param spacingUnit - The spacing unit as described in EN13757-2018 Annex F.\n * @param spacingValue - The spacing value as described in EN13757-2018 Annex F.\n * @returns - The status of the operation.\n */\nfunction getNextTimestamp(timestamp, spacingUnit, spacingValue) {\n    if (spacingValue > 0 && spacingValue < 251) {\n        if (spacingUnit == 0) { // Seconds\n            timestamp.setUTCSeconds(timestamp.getUTCSeconds() - spacingValue);\n        } else if (spacingUnit == 1) { // Minutes\n            timestamp.setUTCMinutes(timestamp.getUTCMinutes() - spacingValue);\n        } else if (spacingUnit == 2) { // Hours\n            timestamp.setUTCHours(timestamp.getUTCHours() - spacingValue);\n        } else if (spacingUnit == 3) { // Days/Months\n            timestamp.setUTCDate(timestamp.getUTCDate() - spacingValue);\n        }\n    } else if (spacingValue == 254 && spacingUnit == 3) {\n        timestamp.setUTCMonth(timestamp.getUTCMonth() - 1);\n    } else if (spacingValue == 254 && spacingUnit == 2) {\n        timestamp.setUTCMonth(timestamp.getUTCMonth() - 3);\n    } else if (spacingValue == 254 && spacingUnit == 1) {\n        timestamp.setUTCMonth(timestamp.getUTCMonth() - 6);\n    } else {\n        return false;\n    }\n    return true;\n}\n\n/**\n * Normalize a number based on the given resolution.\n * This function multiplies the number by the resolution and rounds it to avoid floating-point precision issues.\n * @param number - The number to normalize.\n * @param resolution - The resolution to use for normalization.\n * @returns - The normalized number.\n */\nfunction normalize(number, resolution) {\n    return Math.round(number * resolution * 1e10) / 1e10;\n}\n\n/**\n * Decode uplink\n * @param {Object} input - An object provided by the IoT Flow framework\n * @param {number[]} input.bytes - Array of bytes represented as numbers as it has been sent from the device\n * @param {number} input.fPort - The Port Field on which the uplink has been sent\n * @param {Date} input.recvTime - The uplink message time recorded by the LoRaWAN network server\n * @returns {DecodedUplink} - The decoded object\n */\nfunction decodeUplink(input) {\n    var result = {\n        data: {},\n        errors: [],\n        warnings: []\n    };\n    var i = 0;\n    var configfield = 0;\n    var raw = input.bytes;\n\n    ////////////////// TPL according to EN 13757-7 //////////////////\n    if (raw.length < 1) {\n        result.errors.push(\"Invalid uplink payload: Could not retrieve CI field\");\n        return result;\n    }\n    var CI = raw[i];\n    i++;\n    if (CI == 0x7A) { // Short data header\n        if (raw.length < i + 4) {\n            result.errors.push(\"Invalid uplink payload: Could not retrieve TPL layer\");\n            return result;\n        }\n        i += 2; // ACC, STS\n        configfield = raw[i] | (raw[i + 1] << 8);\n        i += 2;\n    } else if (CI == 0x72) { // Long data header\n        if (raw.length < i + 12) {\n            result.errors.push(\"Invalid uplink payload: Could not retrieve TPL layer\");\n            return result;\n        }\n        i += 10; // IdentNo, Manu, Ver, DevType, ACC, STS\n        configfield = raw[i] | (raw[i + 1] << 8);\n        i += 2;\n    } else if (CI == 0x78) { // No data header\n        // Do nothing\n    } else { // Unsupported header\n        result.errors.push(\"Invalid uplink payload: Invalid CI in TPL layer\");\n        return result;\n    }\n    if ((configfield & 0x1F00) != 0x0) { // Security mode different from 0\n        result.errors.push(\"Invalid uplink payload: MBus TPL encryption is not supported\");\n        return result;\n    }\n\n    ////////////////// APL according to EN 13757-3 //////////////////\n    var temp;\n    var mbusRecords = [];\n    while (i < raw.length) { // Check for more records\n        var record = {\n            dib: {},\n            vib: {},\n            data: {},\n            profileData: {},\n        };\n\n        temp = raw[i];\n        i++;\n        if (temp == 0x2F) {\n            // Skip Filler bytes\n        } else if (temp == 0x0F || temp == 0x1F || temp == 0x7F) {\n            // Unsupported special DIF functions\n            result.errors.push(\"Invalid uplink payload: Unsupported special DIF function\");\n            return result;\n        } else { // No special function - continue\n            // DIB\n            record.dib.datafield = temp & 0xF;\n            record.dib.functionfield = (temp & 0x30) >> 4;\n            record.dib.storagenumber = (temp & 0x40) >> 6;\n            var snBitShift = 1;\n            while ((temp & 0x80) != 0 && i < raw.length) { // Extension\n                temp = raw[i];\n                i++;\n                record.dib.storagenumber += ((temp & 0xF) << snBitShift);\n                snBitShift += 4;\n            }\n            // VIB\n            temp = raw[i];\n            i++;\n            var vibBytes = [temp];\n            while ((temp & 0x80) != 0 && i < raw.length) { // Extension\n                temp = raw[i];\n                i++;\n                vibBytes.push(temp);\n            }\n            // Parse VIF code\n            record.vib = parseVIB(vibBytes);\n            if (record.vib == null) {\n                // Unsupported special VIB functions\n                result.errors.push(\"Invalid uplink payload: Unsupported VIB\");\n                return result;\n            }\n            // Data\n            var sizeByte = 0;\n            var bcd = false;\n            var lvar = false;\n            switch (record.dib.datafield) {\n                case 0:\n                case 0x8:\n                    // No data\n                    sizeByte = 0;\n                    break;\n                case 0x9:\n                    bcd = true;\n                case 0x1:\n                    sizeByte = 1;\n                    break;\n                case 0xA:\n                    bcd = true;\n                case 0x2:\n                    sizeByte = 2;\n                    break;\n                case 0xB:\n                    bcd = true;\n                case 0x3:\n                    sizeByte = 3;\n                    break;\n                case 0xC:\n                    bcd = true;\n                case 0x4:\n                case 0x5:\n                    sizeByte = 4;\n                    break;\n                case 0xE:\n                    bcd = true;\n                case 0x6:\n                    sizeByte = 6;\n                    break;\n                case 0x7:\n                    sizeByte = 8;\n                    break;\n                case 0xD:\n                    sizeByte = 1; // Lvar # bytes\n                    lvar = true;\n                    break;\n            }\n            if (raw.length < i + sizeByte || sizeByte > 6) {\n                result.errors.push(\"Invalid uplink payload: Not enough bytes for datafield or datafield is larger than 6 bytes\");\n                return result;\n            }\n            if (!lvar) {\n                if (bcd) {\n                    record.data = TypeA(raw, i, sizeByte);\n                } else {\n                    if (record.vib.ConversionType == \"C\") {\n                        record.data = TypeC(raw, i, sizeByte);\n                    } else if (record.vib.ConversionType == \"B\") {\n                        record.data = TypeB(raw, i, sizeByte);\n                    } else if (record.vib.ConversionType == \"D\") {\n                        record.data = TypeD(raw, i, sizeByte);\n                    } else if (record.vib.ConversionType == \"G\") {\n                        record.data = TypeG(raw, i, sizeByte);\n                    } else if (record.vib.ConversionType == \"F/J/I/M\") {\n                        if (sizeByte == 4) {\n                            record.data = TypeF(raw, i, sizeByte);\n                        } else if (sizeByte == 6) {\n                            record.data = TypeI(raw, i, sizeByte);\n                        }\n                    }\n                }\n                i += sizeByte;\n            } else { // Lvar\n                var nbBytes = raw[i];\n                i++;\n                if (raw.length < i + nbBytes || nbBytes < 3) {\n                    result.errors.push(\"Invalid uplink payload: Not enough bytes for LVAR\");\n                    return result;\n                }\n                if (!record.vib.isProfileData) {\n                    result.errors.push(\"Invalid uplink payload: LVAR that is not Inverse Compact Profile is not supported\");\n                    return result;\n                }\n                record.profileData = InverseCompactProfile(raw, i, nbBytes);\n                i += nbBytes;\n                if (record.profileData == null) {\n                    result.errors.push(\"Invalid uplink payload: Could not parse Inverse Compact Profile\");\n                    return result;\n                }\n            }\n            mbusRecords.push(record);\n        }\n    }\n\n    // Append functionfield and orthogonal VIFE to type\n    for (var l = 0; l < mbusRecords.length; l++) {\n        var functionFieldText = \"\";\n        if (mbusRecords[l].vib.OrthoVife != \"NA\" && mbusRecords[l].vib.OrthoVife != \"Inverse Compact Profile\") {\n            // Append orthogonal VIFE in beginning of type, e.g., \"Reverse \"flow.\n            mbusRecords[l].vib.type = mbusRecords[l].vib.OrthoVife + \" \" + mbusRecords[l].vib.type;\n        }\n        switch (mbusRecords[l].dib.functionfield) {\n            case 0x0:\n                functionFieldText = \"\"; // Instantaneous value is left untouched\n                break;\n            case 0x1:\n                functionFieldText = \"Max \"; // Instantaneous value is left untouched\n                break;\n            case 0x2:\n                functionFieldText = \"Min \"; // Instantaneous value is left untouched\n                break;\n            case 0x3:\n                functionFieldText = \"Error state \"; // Instantaneous value is left untouched\n                break;\n        }\n        // Append functionfield in beginning of type, e.g., \"Max \"flow.\n        mbusRecords[l].vib.type = functionFieldText + mbusRecords[l].vib.type;\n    }\n\n    // Retrieve timestamps\n    var timestamps = {};\n    for (var k = 0; k < mbusRecords.length; k++) {\n        if (mbusRecords[k].vib.type == \"Date/time\") {\n            if (mbusRecords[k].data !== undefined) {\n                timestamps[mbusRecords[k].dib.storagenumber] = mbusRecords[k].data;\n            } else {\n                result.warnings.push(\"Invalid value among timestamps\");\n            }\n        }\n    }\n\n    // Generate the output data\n    result.data.values = [];\n    for (var p = 0; p < mbusRecords.length; p++) {\n        var type;\n        var value = null;\n        var unit;\n        var timestamp = null;\n\n        var currentRecord = mbusRecords[p];\n        // Add records to values array\n        if (currentRecord.vib.type == \"Date/time\") { // Skip timestamps as they are mapped directly onto records\n            // Skip\n        } else if (currentRecord.vib.type.includes(\"Infocode\") && currentRecord.data !== undefined) { // Special infocode handling\n            for (var j = 0; j < Object.keys(InfocodeTable).length; j++) {\n                type = InfocodeTable[j];\n                value = (currentRecord.data & (1 << j)) != 0;\n                unit = currentRecord.vib.unit;\n                if (currentRecord.dib.storagenumber in timestamps) {\n                    if (timestamps[currentRecord.dib.storagenumber] instanceof Date) {\n                        timestamp = timestamps[currentRecord.dib.storagenumber].toISOString().replace(\"Z\",\"\");\n                    }\n                }\n                result.data.values.push({ Type: type, Value: value, Unit: unit, Timestamp: timestamp });\n            }\n        } else if (!currentRecord.vib.isProfileData) { // Regular values\n            type = currentRecord.vib.type;\n            unit = currentRecord.vib.unit;\n            // If invalid ALD\n            if (type === \"ALD last day\" && currentRecord.data == 4095) {\n                currentRecord.data = undefined;\n            }\n            // Normalize and add data if it is not undefined\n            if (currentRecord.data !== undefined) {\n                value = normalize(currentRecord.data, currentRecord.vib.resolution);\n            } else {\n                unit = \"Invalid\";\n                result.warnings.push(\"Invalid value among data\");\n            }\n            // Add timestamp\n            if (currentRecord.dib.storagenumber in timestamps) {\n                if (timestamps[currentRecord.dib.storagenumber] instanceof Date) {\n                    timestamp = timestamps[currentRecord.dib.storagenumber].toISOString().replace(\"Z\",\"\");\n                }\n            }\n            result.data.values.push({ Type: type, Value: value, Unit: unit, Timestamp: timestamp });\n            if (type === \"Volume\") { // Special handling for latest volume\n                result.data.latestVolume = value;\n            }\n        } else { // Profile values\n            type = currentRecord.vib.type;\n            unit = currentRecord.vib.unit;\n            // Find base value where storage number and vib type, unit and resolution fits\n            var baserecord = mbusRecords.find(item => item.vib.type === type && item.vib.unit === unit && item.dib.storagenumber === currentRecord.dib.storagenumber &&\n                item.vib.resolution === currentRecord.vib.resolution && item.vib.isProfileData !== currentRecord.vib.isProfileData);\n            if (baserecord === undefined || baserecord.data === undefined) {\n                result.errors.push(\"Invalid uplink payload: Could not find base value for profile data\");\n                return result;\n            }\n            var tempVal = baserecord.data;\n            // Find base time\n            if (currentRecord.dib.storagenumber in timestamps) {\n                timestamp = timestamps[currentRecord.dib.storagenumber];\n            } else {\n                result.errors.push(\"Invalid uplink payload: Could not find base time for profile data\");\n                return result;\n            }\n            var ts = new Date(timestamp.getTime()); // Clone timestamp\n            // Loop through delta values\n            for (var m = 0; m < currentRecord.profileData.profileValues.length; m++) {\n                var deltavalue = currentRecord.profileData.profileValues[m];\n                var status = getNextTimestamp(ts, currentRecord.profileData.spacingUnit, currentRecord.profileData.spacingValue);\n                if (deltavalue === undefined || status == false || isNaN(ts)) {\n                    result.warnings.push(\"Invalid value among profile values or timestamps for profile values\");\n                    break;\n                }\n                // Value\n                if (currentRecord.profileData.incMode == 2) {\n                    tempVal += deltavalue;\n                } else {\n                    tempVal -= deltavalue;\n                }\n                value = normalize(tempVal, currentRecord.vib.resolution);\n                // Timestamp\n                timestamp = ts.toISOString().replace(\"Z\",\"\");\n                result.data.values.push({ Type: type, Value: value, Unit: unit, Timestamp: timestamp });\n            }\n        }\n    }\n    return result;\n}\n",
            "environment": "javascript",
            "storage": "",
            "version": "1.0"
          },
          "flows": {
            "kamstrup_decoder": {
              "data": {
                "payload": "{{payload}}",
                "payload_function": "decodeThingerUplink",
                "payload_type": "source_payload",
                "resource": "uplink",
                "source": "resource",
                "update": "events"
              },
              "enabled": true,
              "sink": {
                "payload": "{{payload}}",
                "payload_function": "",
                "payload_type": "source_payload",
                "resource_stream": "uplink_decoded",
                "target": "resource_stream"
              },
              "split_data": false
            }
          },
          "properties": {
            "uplink": {
              "data": {
                "payload": "{{payload}}",
                "payload_function": "",
                "payload_type": "source_payload",
                "resource": "uplink",
                "source": "resource",
                "update": "events"
              },
              "default": {
                "source": "value"
              },
              "enabled": true
            }
          }
        },
        "_resources": {
          "properties": [
            {
              "property": "dashboard",
              "value": {
                "tabs": [
                  {
                    "name": "Main",
                    "widgets": [
                      {
                        "layout": {
                          "col": 0,
                          "row": 0,
                          "sizeX": 2,
                          "sizeY": 5
                        },
                        "panel": {
                          "color": "#ffffff",
                          "currentColor": "#ffffff",
                          "showOffline": {
                            "type": "none"
                          },
                          "title": "Latest Volume"
                        },
                        "properties": {
                          "decimalPlaces": 2,
                          "enableExtraTextColor": false,
                          "enableIconColor": false,
                          "enableIconSize": false,
                          "extraText": "",
                          "extraTextColor": "#1E313E",
                          "extraTextColorConditions": [],
                          "extraTextConditions": [],
                          "extraTextPosition": "above-value",
                          "extraTextSize": "20px",
                          "extraTextWeight": "font-light",
                          "icon": "",
                          "iconColor": "#1E313E",
                          "iconColorConditions": [],
                          "iconConditions": [],
                          "iconGap": "8px",
                          "iconPosition": "before-value",
                          "iconSize": "75px",
                          "iconVerticalOffset": "0px",
                          "link": "",
                          "textAlign": "center",
                          "textColor": "#1E313E",
                          "textColorConditions": [],
                          "textSize": "75px",
                          "textWeight": "font-light",
                          "unit": "m³",
                          "unitSize": "20px"
                        },
                        "sources": [
                          {
                            "bucket": {
                              "backend": "mongodb",
                              "id": "kamstrup_flowiq2200_data",
                              "mapping": "latestVolume",
                              "tags": {
                                "device": [],
                                "group": []
                              }
                            },
                            "color": "#1abc9c",
                            "name": "Volume",
                            "source": "bucket",
                            "timespan": {
                              "mode": "latest"
                            }
                          }
                        ],
                        "type": "text"
                      },
                      {
                        "layout": {
                          "col": 2,
                          "row": 0,
                          "sizeX": 4,
                          "sizeY": 10
                        },
                        "panel": {
                          "color": "#ffffff",
                          "currentColor": "#ffffff",
                          "showOffline": {
                            "type": "none"
                          },
                          "title": "Volume (7 days)"
                        },
                        "properties": {
                          "alignTimeSeries": false,
                          "dataAppend": false,
                          "options": "var options = {\n    chart: {\n        type: 'area',\n        stacked: false\n    },\n    dataLabels: {\n        enabled: false\n    },\n    stroke: {\n        curve: 'smooth'\n    },\n    xaxis: {\n        type: 'datetime',\n        labels: {\n            datetimeUTC: false\n        },\n        tooltip: {\n            enabled: false\n        }\n    },\n    yaxis: {\n        labels: {\n            formatter: function (val) {\n                if ( val !== null && typeof val !== 'undefined' )\n                    return val.toFixed(2);\n            }\n        },\n        title: {\n            text: 'm³'\n        }\n    },\n    tooltip: {\n        x: {\n            format: 'dd/MM/yyyy HH:mm:ss'\n        }\n    }\n};\n",
                          "realTimeUpdate": true
                        },
                        "sources": [
                          {
                            "aggregation": {},
                            "bucket": {
                              "backend": "mongodb",
                              "id": "kamstrup_flowiq2200_data",
                              "mapping": "latestVolume",
                              "tags": {
                                "device": [],
                                "group": []
                              }
                            },
                            "color": "#1E88E5",
                            "name": "Volume",
                            "source": "bucket",
                            "timespan": {
                              "magnitude": "day",
                              "mode": "relative",
                              "period": "latest",
                              "value": 7
                            }
                          }
                        ],
                        "type": "apex_charts"
                      },
                      {
                        "layout": {
                          "col": 0,
                          "row": 5,
                          "sizeX": 2,
                          "sizeY": 5
                        },
                        "panel": {
                          "color": "#ffffff",
                          "currentColor": "#ffffff",
                          "showOffline": {
                            "type": "none"
                          },
                          "title": "Flow Temperature"
                        },
                        "properties": {
                          "decimalPlaces": 1,
                          "enableExtraTextColor": false,
                          "enableIconColor": false,
                          "enableIconSize": false,
                          "extraText": "",
                          "extraTextColor": "#1E313E",
                          "extraTextColorConditions": [],
                          "extraTextConditions": [],
                          "extraTextPosition": "above-value",
                          "extraTextSize": "20px",
                          "extraTextWeight": "font-light",
                          "icon": "",
                          "iconColor": "#1E313E",
                          "iconColorConditions": [],
                          "iconConditions": [],
                          "iconGap": "8px",
                          "iconPosition": "before-value",
                          "iconSize": "60px",
                          "iconVerticalOffset": "0px",
                          "link": "",
                          "textAlign": "center",
                          "textColor": "#1E313E",
                          "textColorConditions": [],
                          "textSize": "60px",
                          "textWeight": "font-light",
                          "unit": "°C",
                          "unitSize": "20px"
                        },
                        "sources": [
                          {
                            "color": "#FF5722",
                            "device": {
                              "id": "{{device}}",
                              "interval": 5000,
                              "property": "flow_temperature"
                            },
                            "name": "Temperature",
                            "source": "property"
                          }
                        ],
                        "type": "text"
                      },
                      {
                        "layout": {
                          "col": 2,
                          "row": 10,
                          "sizeX": 4,
                          "sizeY": 8
                        },
                        "panel": {
                          "color": "#ffffff",
                          "currentColor": "#ffffff",
                          "showOffline": {
                            "type": "none"
                          },
                          "title": "Alarms / Status"
                        },
                        "properties": {
                          "alignTimeSeries": false,
                          "dataAppend": false,
                          "options": "var options = {\n    chart: {\n        type: 'bar',\n        stacked: true\n    },\n    plotOptions: {\n        bar: {\n            horizontal: true\n        }\n    },\n    dataLabels: {\n        enabled: false\n    },\n    xaxis: {\n        type: 'datetime',\n        labels: {\n            datetimeUTC: false\n        }\n    },\n    yaxis: {\n        title: {\n            text: 'Alarm Status'\n        }\n    },\n    tooltip: {\n        x: {\n            format: 'dd/MM/yyyy HH:mm:ss'\n        }\n    },\n    colors: ['#FF5252', '#FF6F00', '#FDD835', '#29B6F6', '#AB47BC', '#EC407A', '#66BB6A', '#EF5350']\n};\n",
                          "realTimeUpdate": true
                        },
                        "sources": [
                          {
                            "color": "#FF5252",
                            "device": {
                              "id": "{{device}}",
                              "interval": 5000,
                              "property": "Dry"
                            },
                            "name": "Dry",
                            "source": "property"
                          },
                          {
                            "color": "#FF6F00",
                            "device": {
                              "id": "{{device}}",
                              "interval": 5000,
                              "property": "Reverse"
                            },
                            "name": "Reverse",
                            "source": "property"
                          },
                          {
                            "color": "#FDD835",
                            "device": {
                              "id": "{{device}}",
                              "interval": 5000,
                              "property": "Leak"
                            },
                            "name": "Leak",
                            "source": "property"
                          },
                          {
                            "color": "#29B6F6",
                            "device": {
                              "id": "{{device}}",
                              "interval": 5000,
                              "property": "Burst"
                            },
                            "name": "Burst",
                            "source": "property"
                          },
                          {
                            "color": "#AB47BC",
                            "device": {
                              "id": "{{device}}",
                              "interval": 5000,
                              "property": "Tamper"
                            },
                            "name": "Tamper",
                            "source": "property"
                          },
                          {
                            "color": "#EC407A",
                            "device": {
                              "id": "{{device}}",
                              "interval": 5000,
                              "property": "Low Battery"
                            },
                            "name": "Low Battery",
                            "source": "property"
                          }
                        ],
                        "type": "apex_charts"
                      },
                      {
                        "layout": {
                          "col": 0,
                          "row": 10,
                          "sizeX": 2,
                          "sizeY": 8
                        },
                        "panel": {
                          "color": "#ffffff",
                          "currentColor": "#ffffff",
                          "showOffline": {
                            "type": "none"
                          },
                          "title": "Recent Data"
                        },
                        "properties": {
                          "source": "code",
                          "template": "<div style=\"width:100%; height:100%; overflow-y:auto; padding:10px;\">\n  <table class=\"table table-striped table-condensed\">\n    <thead>\n      <tr>\n        <th>Timestamp</th>\n        <th>Volume (m³)</th>\n      </tr>\n    </thead>\n    <tbody>\n      <tr ng-repeat=\"entry in value | limitTo:10\">\n        <td>{{ entry.ts | date:'medium' }}</td>\n        <td>{{ (entry.latestVolume || 0).toFixed(2) }}</td>\n      </tr>\n    </tbody>\n  </table>\n</div>\n"
                        },
                        "sources": [
                          {
                            "aggregation": {},
                            "bucket": {
                              "backend": "mongodb",
                              "id": "kamstrup_flowiq2200_data",
                              "mapping": "ts",
                              "tags": {
                                "device": [],
                                "group": []
                              }
                            },
                            "color": "#1abc9c",
                            "name": "ts",
                            "source": "bucket",
                            "timespan": {
                              "magnitude": "day",
                              "mode": "relative",
                              "period": "latest",
                              "value": 1
                            }
                          },
                          {
                            "aggregation": {},
                            "bucket": {
                              "backend": "mongodb",
                              "id": "kamstrup_flowiq2200_data",
                              "mapping": "latestVolume",
                              "tags": {
                                "device": [],
                                "group": []
                              }
                            },
                            "color": "#1E88E5",
                            "name": "volume",
                            "source": "bucket",
                            "timespan": {
                              "magnitude": "day",
                              "mode": "relative",
                              "period": "latest",
                              "value": 1
                            }
                          }
                        ],
                        "type": "html_time"
                      }
                    ]
                  }
                ]
              }
            }
          ]
        }
      }
    ]
  }
}