From 69bd848fda659899a02b0c13a286f638ea47aa95 Mon Sep 17 00:00:00 2001 From: Christoph Gohle Date: Fri, 29 Nov 2024 19:14:54 +0100 Subject: [PATCH] reverse engineered shadow management and write protection registers --- README.md | 29 +++++++++++++++++++++++++++++ kaco_inverter.py | 32 ++++++++++++++++++++++++++++---- 2 files changed, 57 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 8eec53e..782f80a 100644 --- a/README.md +++ b/README.md @@ -123,3 +123,32 @@ serial number as an argument. I'm going to write a module for the excellent Victron VenusOS to display the values of the individual inverters. +## undocumented modbus registers +Most of the modbus registers follow the SunSpec specification. However some features of the Kaco inverters are not covered by it and are not documented by Kaco otherwise. Using the procedures described above, I reverseengineered in particular the registers to control write access and shadow management. + +The Kaco app is using the fdbg.cgi api to directly send/receive modbus telegrams. Picking up what is sent by the Kaco app when entering the "shadow management" section of an inverter, we find that we are receiving a POST on fdbg.cgi with data + + {"data":"03030fb8000106d9"} + +interpreting this using the modbus specification: + +* The first byte 03 is the modbus address. +* The second byte 03 is the 'read holding registers' +* next two bytes are starting address. In this case 0x0fb8 +* next two bytes are real length. In this case 0x0001. reading one register (two bytes long) +* the final two bytes are the CRC code of the entire command. + +The response of the kaco inverter to this post is either of the following two + + content = '{"dat":"ok","data":"0303020000c184"}' #off case + content = '{"dat":"ok","data":"03030200010044"}' #on case + +and is the expected response to the read command accoring to modbus spec. Counting bytes starting with zero: +address 03 (byte 0), command 'read holding register' (byte 1), two bytes long (byte 2) response in bytes 3 and 4 and CRC code (bytes 5 and 6) + +Similarily, posting {"data":"03060fb80001cad9"} on the fdbg.cgi turns on shadow management. and posting {"data":"03060fb800000b19"} turns it off. + +the same manipulation can be done using the modbus TCP protocol. The register adress for shadow management is 0x0fb8 or 4024. allowed values are 0x0000 and 0x0001. + +Another register that was reverse engineered like this is the write access control register which is at 0x0fba or 4026. + diff --git a/kaco_inverter.py b/kaco_inverter.py index 1e96e3b..d9e4f0c 100644 --- a/kaco_inverter.py +++ b/kaco_inverter.py @@ -20,25 +20,49 @@ def setting(): @app.route('/getdevdata.cgi', methods=['GET']) def getdevdata(device=2, sn="1234"): - content = '{"flg":1,"tim":"20221111153846","tmp":351,"fac":4998,"pac":0,"sac":0,"qac":0,"eto":464,"etd":61,"hto":59,"pf":0,"wan":0,"err":0,"vac":[2325,2327,2341],"iac":[5,5,5],"vpv":[1500,1497],"ipv":[0,1],"str":[]}' + content = '{"flg":1,"tim":"20241124112956","tmp":284,"fac":4999,"pac":1026,"sac":1026,"qac":0,"eto":23634,"etd":17,"hto":1526,"pf":100,"wan":0,"err":0,"vac":[2236,2254,2267],"iac":[16,15,16],"vpv":[7619,0],"ipv":[132,0],"str":[]}' return Response(content, mimetype='application/json') @app.route('/getdev.cgi', methods=['GET']) def getdev(device=0): if request.args.get('device', 0) == "2": - content = '{"inv":[{"isn":"8.0NX312001234","add":3,"safety":70,"rate":8000,"msw":"V610-03043-04 ","ssw":"V610-60009-00 ","tsw":"V610-11009-01 ","pac":386,"etd":58,"eto":461,"err":0,"cmv":"V2.1.1AV2.00","mty":51,"psb_eb":1}],"num":1}' + content = '{"inv":[{"isn":"8.0NX312064373","add":3,"safety":70,"rate":8000,"msw":"V610-03043-05 ","ssw":"V610-60009-00 ","tsw":"V610-11009-02 ","pac":1026,"etd":17,"eto":23634,"err":0,"cmv":"V2.1.2 ","mty":51,"psb_eb":1}],"num":1}' else: - content = '{"psn":"1234567890","key":"1234567890","typ":5,"nam":"Wi-Fi Stick","mod":"B32078-10","muf":"KACO","brd":"KACO","hw":"M11","sw":"21618-006R","wsw":"ESP32-WROOM-32U","tim":"2022-11-11 13:19:34","pdk":"","ser":"","protocol":"V1.0","host":"cn-shanghai","port":1883,"status":-1}' + content = '{"psn":"B3278A2C2448","key":"EFM4VBWVVXRKXMPG","typ":5,"nam":"Wi-Fi Stick","mod":"B32078-10","muf":"KACO","brd":"KACO","hw":"M11","sw":"21618-006R","wsw":"ESP32-WROOM-32U","tim":"2024-11-24 11:32:06","pdk":"","ser":"","protocol":"V1.0","host":"cn-shanghai","port":1883,"status":-1}' return Response(content, mimetype='application/json') @app.route('/wlanget.cgi') def wlanget(): - return Response('{"mode":"STATION","sid":"lasseredn","srh":-42,"ip":"10.0.0.2","gtw":"10.0.0.1","msk":"255.255.255.0"}', mimetype='application/json') + return Response('{"mode":"STATION","sid":"Gohle","srh":-70,"ip":"192.168.178.182","gtw":"192.168.178.1","msk":"255.255.255.0"}', mimetype='application/json') + +@app.route('/fdbg.cgi', methods=['POST', 'GET']) +def fdbg(path=None): + print(path) + print(request.args, request.data, request.path, request.method) + if request.data == b'{"data":"03030fb8000106d9"}': #read shadow management status + content = '{"dat":"ok","data":"0303020000c184"}' #off case + content = '{"dat":"ok","data":"03030200010044"}' #on case + elif request.data == b'{"data":"03060fb80001cad9"}': #turn shadow management on + content = '{"dat":"ok","data":"03060fb80001cad9"}' + elif request.data == b'{"data":"03060fb800000b19"}': #turn shadow management off + content = '{"dat":"ok","data":"03060fb800000b19"}' + elif request.data == b'{"data":"03030fba0001a719"}': #read external write access status + content = '{"dat":"ok","data":"0303020000c184"}' #off case + content = '{"dat":"ok","data":"03030200010044"}' #on case + elif request.data == b'{"data":"03060fba00016b19"}': #turn external write on + content = '{"dat":"ok","data":"03060fba00016b19"}' + elif request.data == b'{"data":"03060fba0000aad9"}': #turn shadow management off + content = '{"dat":"ok","data":"03060fba0000aad9"}' + else: + content = 'fdbg.cgi: Not implemented data: %s'%request.data + print(content) + return Response(content, mimetype='application/jsoon') @app.route('/', methods=['POST', 'GET']) def catch_all(path): + print("DEBUG: Before crash", path, request.data) app.logger.info('Catch-All %s: %s', (path, request.data)) return 'Not implemented path: %s' % path