├── README.md ├── cell_voltages.json ├── data_bms.py ├── data_bms_full.py ├── screenshot.png └── temperature.json /README.md: -------------------------------------------------------------------------------- 1 | # JK BMS to Grafana 2 | Read data from a JK BMS through RS-485 and graph it in Grafana. 3 | This script is intended to be used with: 4 | https://github.com/BarkinSpider/SolarShed/ 5 | 6 | The JK BMS can be found on AliExpress and other sites, for example: https://s.click.aliexpress.com/e/_DBEzZwv or https://s.click.aliexpress.com/e/_oCQ9mP3 7 | 8 | Alternatively, a set-up for this is documented here for both Raspberry Pi and plain Debian or derivatives: 9 | https://diysolarforum.com/ewr-carta/data_communication/ 10 | 11 | # Pinout and connection 12 | 13 | The JK BMS has an RS-485 port, but this is actually a TTL UART that gets turned into RS-485 with an optional converter, which in turn can be used with a RS-485 to USB converter. While this works, you don't need this whole chain. You can directly connect a TTL to USB converter to the TTL UART port. The pin-out on the connector: 14 | 15 | ``` 16 | RS485-TTL plug on BMS (4 Pins, JST 1.25mm pinch) 17 | ┌─── ─────── ────┐ 18 | │ │ 19 | │ O O O O │ 20 | │GND RX TX VBAT│ 21 | └────────────────┘ 22 | ``` 23 | 24 | To prevent issues, ground loops (the GND pin connects directly to battery negative), and other problems, you should use an isolated UART/USB converter or isolated UART/UART if you connect this directly to another microcontroller. Failing that, use a USB to USB isolator like this one (based on an ADuM3160): https://s.click.aliexpress.com/e/_onvOeBT if you connect to USB on an R-Pi or something. If you want to connect more than one UART/USB converter, you can also find these isolators as a USB Hub with four ports: https://s.click.aliexpress.com/e/_oFScxdF 25 | 26 | See this post on DIY Solar Forum for the locaton of this port on various versions of the BMS: 27 | https://diysolarforum.com/threads/victron-venusos-driver-for-serial-connected-bms-llt-jbd-daly-smart-ant-jkbms-heltec-renogy.17847/post-424921 28 | -------------------------------------------------------------------------------- /cell_voltages.json: -------------------------------------------------------------------------------- 1 | { 2 | "description": "", 3 | "fieldConfig": { 4 | "defaults": { 5 | "custom": {}, 6 | "thresholds": { 7 | "mode": "absolute", 8 | "steps": [ 9 | { 10 | "color": "dark-red", 11 | "value": null 12 | }, 13 | { 14 | "color": "dark-red", 15 | "value": 2.5 16 | }, 17 | { 18 | "color": "dark-yellow", 19 | "value": 2.6 20 | }, 21 | { 22 | "color": "dark-green", 23 | "value": 3 24 | }, 25 | { 26 | "color": "dark-yellow", 27 | "value": 3.5 28 | }, 29 | { 30 | "color": "semi-dark-red", 31 | "value": 3.6 32 | }, 33 | { 34 | "color": "dark-red", 35 | "value": 3.65 36 | } 37 | ] 38 | }, 39 | "mappings": [], 40 | "color": { 41 | "mode": "thresholds" 42 | }, 43 | "decimals": 3, 44 | "max": 3.75, 45 | "min": 2.4 46 | }, 47 | "overrides": [] 48 | }, 49 | "gridPos": { 50 | "h": 8, 51 | "w": 12, 52 | "x": 0, 53 | "y": 15 54 | }, 55 | "id": 74, 56 | "options": { 57 | "reduceOptions": { 58 | "values": false, 59 | "calcs": [ 60 | "lastNotNull" 61 | ], 62 | "fields": "" 63 | }, 64 | "orientation": "vertical", 65 | "text": {}, 66 | "displayMode": "basic", 67 | "showUnfilled": true 68 | }, 69 | "pluginVersion": "7.4.3", 70 | "targets": [ 71 | { 72 | "exemplar": false, 73 | "expr": "JK_BMS{mode=\"cell1_BMS\"}", 74 | "instant": false, 75 | "interval": "", 76 | "legendFormat": "1", 77 | "refId": "A" 78 | }, 79 | { 80 | "exemplar": false, 81 | "expr": "JK_BMS{mode=\"cell2_BMS\"}", 82 | "hide": false, 83 | "instant": false, 84 | "interval": "", 85 | "legendFormat": "2", 86 | "refId": "B" 87 | }, 88 | { 89 | "exemplar": false, 90 | "expr": "JK_BMS{mode=\"cell3_BMS\"}", 91 | "hide": false, 92 | "instant": false, 93 | "interval": "", 94 | "legendFormat": "3", 95 | "refId": "C" 96 | }, 97 | { 98 | "exemplar": false, 99 | "expr": "JK_BMS{mode=\"cell4_BMS\"}", 100 | "hide": false, 101 | "instant": false, 102 | "interval": "", 103 | "legendFormat": "4", 104 | "refId": "D" 105 | }, 106 | { 107 | "exemplar": false, 108 | "expr": "JK_BMS{mode=\"cell5_BMS\"}", 109 | "hide": false, 110 | "instant": false, 111 | "interval": "", 112 | "legendFormat": "5", 113 | "refId": "E" 114 | }, 115 | { 116 | "exemplar": false, 117 | "expr": "JK_BMS{mode=\"cell6_BMS\"}", 118 | "hide": false, 119 | "instant": false, 120 | "interval": "", 121 | "legendFormat": "6", 122 | "refId": "F" 123 | }, 124 | { 125 | "exemplar": false, 126 | "expr": "JK_BMS{mode=\"cell7_BMS\"}", 127 | "hide": false, 128 | "instant": false, 129 | "interval": "", 130 | "legendFormat": "7", 131 | "refId": "G" 132 | }, 133 | { 134 | "exemplar": false, 135 | "expr": "JK_BMS{mode=\"cell8_BMS\"}", 136 | "hide": false, 137 | "instant": false, 138 | "interval": "", 139 | "legendFormat": "8", 140 | "refId": "H" 141 | }, 142 | { 143 | "exemplar": false, 144 | "expr": "JK_BMS{mode=\"cell9_BMS\"}", 145 | "hide": false, 146 | "instant": false, 147 | "interval": "", 148 | "legendFormat": "9", 149 | "refId": "I" 150 | }, 151 | { 152 | "exemplar": false, 153 | "expr": "JK_BMS{mode=\"cell10_BMS\"}", 154 | "hide": false, 155 | "instant": false, 156 | "interval": "", 157 | "legendFormat": "10", 158 | "refId": "J" 159 | }, 160 | { 161 | "exemplar": false, 162 | "expr": "JK_BMS{mode=\"cell11_BMS\"}", 163 | "hide": false, 164 | "instant": false, 165 | "interval": "", 166 | "legendFormat": "11", 167 | "refId": "K" 168 | }, 169 | { 170 | "exemplar": false, 171 | "expr": "JK_BMS{mode=\"cell12_BMS\"}", 172 | "hide": false, 173 | "instant": false, 174 | "interval": "", 175 | "legendFormat": "12", 176 | "refId": "L" 177 | }, 178 | { 179 | "exemplar": false, 180 | "expr": "JK_BMS{mode=\"cell13_BMS\"}", 181 | "hide": false, 182 | "instant": false, 183 | "interval": "", 184 | "legendFormat": "13", 185 | "refId": "M" 186 | }, 187 | { 188 | "exemplar": false, 189 | "expr": "JK_BMS{mode=\"cell14_BMS\"}", 190 | "hide": false, 191 | "instant": false, 192 | "interval": "", 193 | "legendFormat": "14", 194 | "refId": "N" 195 | }, 196 | { 197 | "exemplar": false, 198 | "expr": "JK_BMS{mode=\"cell15_BMS\"}", 199 | "hide": false, 200 | "instant": false, 201 | "interval": "", 202 | "legendFormat": "15", 203 | "refId": "O" 204 | }, 205 | { 206 | "exemplar": false, 207 | "expr": "JK_BMS{mode=\"cell16_BMS\"}", 208 | "hide": false, 209 | "instant": false, 210 | "interval": "", 211 | "legendFormat": "16", 212 | "refId": "P" 213 | } 214 | ], 215 | "title": "Cell Voltages BMS", 216 | "type": "bargauge", 217 | "datasource": null 218 | } 219 | -------------------------------------------------------------------------------- /data_bms.py: -------------------------------------------------------------------------------- 1 | # This script reads the data from a JB BMS over RS-485 and formats 2 | # it for use with https://github.com/BarkinSpider/SolarShed/ 3 | 4 | import time 5 | import sys, os, io 6 | import struct 7 | 8 | # Plain serial... Modbus would have been nice, but oh well. 9 | import serial 10 | 11 | sleepTime = 10 12 | 13 | try: 14 | bms = serial.Serial('/dev/ttyUSB0') 15 | bms.baudrate = 115200 16 | bms.timeout = 0.2 17 | except: 18 | print("BMS not found.") 19 | 20 | # The hex string composing the command, including CRC check etc. 21 | # See also: 22 | # - https://github.com/syssi/esphome-jk-bms 23 | # - https://github.com/NEEY-electronic/JK/tree/JK-BMS 24 | # - https://github.com/Louisvdw/dbus-serialbattery 25 | 26 | def sendBMSCommand(cmd_string): 27 | cmd_bytes = bytearray.fromhex(cmd_string) 28 | for cmd_byte in cmd_bytes: 29 | hex_byte = ("{0:02x}".format(cmd_byte)) 30 | bms.write(bytearray.fromhex(hex_byte)) 31 | return 32 | 33 | # This could be much better, but it works. 34 | def readBMS(fileObj): 35 | 36 | try: 37 | # Read all command 38 | sendBMSCommand('4E 57 00 13 00 00 00 00 06 03 00 00 00 00 00 00 68 00 00 01 29') 39 | 40 | time.sleep(.1) 41 | 42 | if bms.inWaiting() >= 4 : 43 | if bms.read(1).hex() == '4e' : # header byte 1 44 | if bms.read(1).hex() == '57' : # header byte 2 45 | # next two bytes is the length of the data package, including the two length bytes 46 | length = int.from_bytes(bms.read(2),byteorder='big') 47 | length -= 2 # Remaining after length bytes 48 | 49 | # Lets wait until all the data that should be there, really is present. 50 | # If not, something went wrong. Flush and exit 51 | available = bms.inWaiting() 52 | if available != length : 53 | time.sleep(0.1) 54 | available = bms.inWaiting() 55 | # if it's not here by now, exit 56 | if available != length : 57 | bms.reset_input_buffer() 58 | raise Exception("Something went wrong reading the data...") 59 | 60 | # Reconstruct the header and length field 61 | b = bytearray.fromhex("4e57") 62 | b += (length+2).to_bytes(2, byteorder='big') 63 | 64 | # Read all the data 65 | data = bytearray(bms.read(available)) 66 | # And re-attach the header (needed for CRC calculation) 67 | data = b + data 68 | 69 | # Calculate the CRC sum 70 | crc_calc = sum(data[0:-4]) 71 | # Extract the CRC value from the data 72 | crc_lo = struct.unpack_from('>H', data[-2:])[0] 73 | 74 | # Exit if CRC doesn't match 75 | if crc_calc != crc_lo : 76 | bms.reset_input_buffer() 77 | raise Exception("CRC Wrong") 78 | 79 | # The actual data we need 80 | data = data[11:length-19] # at location 0 we have 0x79 81 | 82 | # The byte at location 1 is the length count for the cell data bytes 83 | # Each cell has 3 bytes representing the voltage per cell in mV 84 | bytecount = data[1] 85 | 86 | # We can use this number to determine the total amount of cells we have 87 | cellcount = int(bytecount/3) 88 | 89 | # Voltages start at index 2, in groups of 3 90 | for i in range(cellcount) : 91 | voltage = struct.unpack_from('>xH', data, i * 3 + 2)[0]/1000 92 | valName = "mode=\"cell"+str(i+1)+"_BMS\"" 93 | valName = "{" + valName + "}" 94 | dataStr = f"JK_BMS{valName} {voltage}" 95 | print(dataStr, file=fileObj) 96 | 97 | # Temperatures are in the next nine bytes (MOSFET, Probe 1 and Probe 2), register id + two bytes each for data 98 | # Anything over 100 is negative, so 110 == -10 99 | temp_fet = struct.unpack_from('>H', data, bytecount + 3)[0] 100 | if temp_fet > 100 : 101 | temp_fet = -(temp_fet - 100) 102 | temp_1 = struct.unpack_from('>H', data, bytecount + 6)[0] 103 | if temp_1 > 100 : 104 | temp_1 = -(temp_1 - 100) 105 | temp_2 = struct.unpack_from('>H', data, bytecount + 9)[0] 106 | if temp_2 > 100 : 107 | temp_2 = -(temp_2 - 100) 108 | 109 | # For now we just show the average between the two probes in Grafana 110 | valName = "mode=\"temp_BMS\"" 111 | valName = "{" + valName + "}" 112 | dataStr = f"JK_BMS{valName} {(temp_1+temp_2)/2}" 113 | print(dataStr, file=fileObj) 114 | 115 | # Battery voltage 116 | voltage = struct.unpack_from('>H', data, bytecount + 12)[0]/100 117 | 118 | # Current 119 | current = struct.unpack_from('>H', data, bytecount + 15)[0]/100 120 | 121 | # Remaining capacity, % 122 | capacity = struct.unpack_from('>B', data, bytecount + 18)[0] 123 | 124 | 125 | bms.reset_input_buffer() 126 | 127 | except Exception as e : 128 | print(e) 129 | 130 | while True: 131 | file_object = open('/ramdisk/JK_BMS.prom.tmp', mode='w') 132 | readBMS(file_object) 133 | file_object.flush() 134 | file_object.close() 135 | outLine = os.system('/bin/mv /ramdisk/JK_BMS.prom.tmp /ramdisk/JK_BMS.prom') 136 | 137 | time.sleep(sleepTime) 138 | 139 | -------------------------------------------------------------------------------- /data_bms_full.py: -------------------------------------------------------------------------------- 1 | # This script reads the data from a JB BMS over RS-485 and formats 2 | # it for use with https://github.com/BarkinSpider/SolarShed/ 3 | 4 | import time 5 | import sys, os, io 6 | import struct 7 | 8 | # Plain serial... Modbus would have been nice, but oh well. 9 | import serial 10 | 11 | sleepTime = 10 12 | 13 | try: 14 | bms1 = serial.Serial('/dev/bms1') 15 | bms1.baudrate = 115200 16 | bms1.timeout = 0.2 17 | except: 18 | print("BMS 1 not found.") 19 | 20 | try: 21 | bms2 = serial.Serial('/dev/bms2') 22 | bms2.baudrate = 115200 23 | bms2.timeout = 0.2 24 | except: 25 | print("BMS 2 not found.") 26 | 27 | try: 28 | bms3 = serial.Serial('/dev/bms3') 29 | bms3.baudrate = 115200 30 | bms3.timeout = 0.2 31 | except: 32 | print("BMS 3 not found.") 33 | 34 | try: 35 | bms4 = serial.Serial('/dev/bms4') 36 | bms4.baudrate = 115200 37 | bms4.timeout = 0.2 38 | except: 39 | print("BMS 4 not found.") 40 | 41 | # The hex string composing the command, including CRC check etc. 42 | # See also: 43 | # - https://github.com/syssi/esphome-jk-bms 44 | # - https://github.com/NEEY-electronic/JK/tree/JK-BMS 45 | # - https://github.com/Louisvdw/dbus-serialbattery 46 | 47 | def sendBMSCommand(bms,cmd_string): 48 | cmd_bytes = bytearray.fromhex(cmd_string) 49 | for cmd_byte in cmd_bytes: 50 | hex_byte = ("{0:02x}".format(cmd_byte)) 51 | bms.write(bytearray.fromhex(hex_byte)) 52 | return 53 | 54 | # This could be much better, but it works. 55 | def readBMS(fileObj,bms,ident): 56 | 57 | # BMS 58 | try : 59 | # cell voltages 60 | sendBMSCommand(bms, '4E 57 00 13 00 00 00 00 06 03 00 00 00 00 00 00 68 00 00 01 29') 61 | 62 | time.sleep(.1) 63 | if bms.inWaiting() >= 4 : 64 | if bms.read(1).hex() == '4e' : # header byte 1 65 | if bms.read(1).hex() == '57' : # header byte 2 66 | # next two bytes is the length of the data package, including the two length bytes 67 | length = int.from_bytes(bms.read(2),byteorder='big') 68 | length -= 2 # Remaining after length bytes 69 | 70 | # Lets wait until all the data that should be there, really is present. 71 | # If not, something went wrong. Flush and exit 72 | available = bms.inWaiting() 73 | if available != length : 74 | time.sleep(0.1) 75 | available = bms.inWaiting() 76 | #if it's not here by now, exit 77 | if available != length : 78 | bms.reset_input_buffer() 79 | raise Exception("Something went wrong reading the data...") 80 | 81 | # Reconstruct the header and length field 82 | b = bytearray.fromhex("4e57") 83 | b += (length+2).to_bytes(2, byteorder='big') 84 | 85 | # Read all the data 86 | data = bytearray(bms.read(available)) 87 | # And re-attach the header (needed for CRC calculation) 88 | data = b + data 89 | 90 | # Calculate the CRC sum 91 | crc_calc = sum(data[0:-4]) 92 | # Extract the CRC value from the data 93 | crc_lo = struct.unpack_from('>H', data[-2:])[0] 94 | 95 | # Exit if CRC doesn't match 96 | if crc_calc != crc_lo : 97 | bms.reset_input_buffer() 98 | raise Exception("CRC Wrong") 99 | 100 | # The actual data we need 101 | data = data[11:length-19] # at location 0 we have 0x79 102 | 103 | # The byte at location 1 is the length count for the cell data bytes 104 | # Each cell has 3 bytes representing the voltage per cell in mV 105 | bytecount = data[1] 106 | 107 | # We can use this number to determine the total amount of cells we have 108 | cellcount = int(bytecount/3) 109 | 110 | voltages = [] 111 | # Voltages start at index 2, in groups of 3 112 | for i in range(cellcount) : 113 | voltage = struct.unpack_from('>xH', data, i * 3 + 2)[0]/1000 114 | voltages.append(voltage) 115 | valName = "mode=\"cell"+str(i+1)+"_BMS"+ident+"\"" 116 | valName = "{" + valName + "}" 117 | dataStr = f"JK_BMS{valName} {voltage}" 118 | print(dataStr, file=fileObj) 119 | 120 | delta = max(voltages) - min(voltages) 121 | valName = "mode=\"delta_BMS"+ident+"_V"+"\"" 122 | valName = "{" + valName + "}" 123 | dataStr = f"JK_BMS{valName} {delta}" 124 | print(dataStr, file=fileObj) 125 | 126 | # Temperatures are in the next nine bytes (MOSFET, Probe 1 and Probe 2), register id + two bytes each for data 127 | # Anything over 100 is negative, so 110 == -10 128 | temp_fet = struct.unpack_from('>H', data, bytecount + 3)[0] 129 | if temp_fet > 100 : 130 | temp_fet = -(temp_fet - 100) 131 | temp_1 = struct.unpack_from('>H', data, bytecount + 6)[0] 132 | if temp_1 > 100 : 133 | temp_1 = -(temp_1 - 100) 134 | temp_2 = struct.unpack_from('>H', data, bytecount + 9)[0] 135 | if temp_2 > 100 : 136 | temp_2 = -(temp_2 - 100) 137 | 138 | # FET + both probes 139 | valName = "mode=\"temp_BMS"+ident+"_FET"+"\"" 140 | valName = "{" + valName + "}" 141 | dataStr = f"JK_BMS{valName} {temp_fet}" 142 | print(dataStr, file=fileObj) 143 | 144 | valName = "mode=\"temp_BMS"+ident+"_probe_1"+"\"" 145 | valName = "{" + valName + "}" 146 | dataStr = f"JK_BMS{valName} {temp_1}" 147 | print(dataStr, file=fileObj) 148 | 149 | valName = "mode=\"temp_BMS"+ident+"_probe_2"+"\"" 150 | valName = "{" + valName + "}" 151 | dataStr = f"JK_BMS{valName} {temp_2}" 152 | print(dataStr, file=fileObj) 153 | 154 | # Battery voltage 155 | voltage = struct.unpack_from('>H', data, bytecount + 12)[0]/100 156 | valName = "mode=\"global_BMS"+ident+"_voltage"+"\"" 157 | valName = "{" + valName + "}" 158 | dataStr = f"JK_BMS{valName} {voltage}" 159 | print(dataStr, file=fileObj) 160 | 161 | # Current 162 | # There are two different versions of the protocol that encode the current differently. 163 | # We distinguish by the lenngth of the data sent. This is likely not going to be correct on non-16s packs. 164 | value = struct.unpack_from('>H', data, bytecount + 15)[0] 165 | current = 0 166 | if length < 260 : 167 | current = (10000 - value)*0.01 168 | else: 169 | if (value & 0x8000) == 0x8000 : 170 | current = (value & 0x7FFF)/100 171 | else : 172 | current = ((value & 0x7FFF)/100) * -1 173 | 174 | valName = "mode=\"global_BMS"+ident+"_current"+"\"" 175 | valName = "{" + valName + "}" 176 | dataStr = f"JK_BMS{valName} {current}" 177 | print(dataStr, file=fileObj) 178 | 179 | # Remaining capacity, % 180 | capacity = struct.unpack_from('>B', data, bytecount + 18)[0] 181 | valName = "mode=\"global_BMS"+ident+"_capacity"+"\"" 182 | valName = "{" + valName + "}" 183 | dataStr = f"JK_BMS{valName} {capacity}" 184 | print(dataStr, file=fileObj) 185 | 186 | bms.reset_input_buffer() 187 | 188 | except Exception as e : 189 | print(e) 190 | 191 | while True: 192 | file_object = open('/ramdisk/JK_BMS.prom.tmp', mode='w') 193 | 194 | if 'bms1' in globals() : 195 | readBMS(file_object,bms1,"1") 196 | 197 | if 'bms2' in globals() : 198 | readBMS(file_object,bms2,"2") 199 | 200 | if 'bms3' in globals() : 201 | readBMS(file_object,bms3,"3") 202 | 203 | if 'bms4' in globals() : 204 | readBMS(file_object,bms4,"4") 205 | 206 | file_object.flush() 207 | file_object.close() 208 | outLine = os.system('/bin/mv /ramdisk/JK_BMS.prom.tmp /ramdisk/JK_BMS.prom') 209 | 210 | time.sleep(sleepTime) 211 | -------------------------------------------------------------------------------- /screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/PurpleAlien/jk-bms_grafana/459ca100356350c43ab0f3b64652f1efb790e849/screenshot.png -------------------------------------------------------------------------------- /temperature.json: -------------------------------------------------------------------------------- 1 | { 2 | "description": "", 3 | "fieldConfig": { 4 | "defaults": { 5 | "custom": {}, 6 | "thresholds": { 7 | "mode": "absolute", 8 | "steps": [ 9 | { 10 | "color": "dark-red", 11 | "value": null 12 | }, 13 | { 14 | "color": "dark-red", 15 | "value": 0 16 | }, 17 | { 18 | "color": "dark-orange", 19 | "value": 5 20 | }, 21 | { 22 | "color": "dark-yellow", 23 | "value": 10 24 | }, 25 | { 26 | "color": "dark-green", 27 | "value": 15 28 | }, 29 | { 30 | "color": "#EAB839", 31 | "value": 30 32 | }, 33 | { 34 | "color": "dark-red", 35 | "value": 40 36 | } 37 | ] 38 | }, 39 | "mappings": [], 40 | "decimals": 0, 41 | "max": 60, 42 | "min": -20, 43 | "unit": "celsius" 44 | }, 45 | "overrides": [] 46 | }, 47 | "gridPos": { 48 | "h": 5, 49 | "w": 5, 50 | "x": 0, 51 | "y": 23 52 | }, 53 | "id": 79, 54 | "options": { 55 | "reduceOptions": { 56 | "values": false, 57 | "calcs": [ 58 | "last" 59 | ], 60 | "fields": "" 61 | }, 62 | "text": {}, 63 | "showThresholdLabels": false, 64 | "showThresholdMarkers": true, 65 | "orientation": "auto" 66 | }, 67 | "pluginVersion": "7.4.3", 68 | "targets": [ 69 | { 70 | "expr": "JK_BMS{mode=\"temp_BMS\"}", 71 | "format": "time_series", 72 | "instant": true, 73 | "interval": "", 74 | "legendFormat": "Battery Current", 75 | "refId": "A" 76 | } 77 | ], 78 | "timeFrom": null, 79 | "timeShift": null, 80 | "title": "Temperature BMS", 81 | "type": "gauge", 82 | "datasource": null 83 | } 84 | --------------------------------------------------------------------------------