commit c8a112bbd143652b80839859adeec2d0e7f91b14
Author: Leah (ctucx) <git@ctu.cx>
Date: Thu, 4 May 2023 21:41:25 +0200
Author: Leah (ctucx) <git@ctu.cx>
Date: Thu, 4 May 2023 21:41:25 +0200
initial commit
10 files changed, 537 insertions(+), 0 deletions(-)
diff --git a/README.md b/README.md @@ -0,0 +1,9 @@ +# solax2mqtt + +This program allows export of data from the Solax Pocket Wifi Module to an MQTT broker. + +It currently supports the following inverters: + - X3 Hybrid G4 (tested) + - X1 Hybrid G4 (untested) + +It should be easy to implement support for other inverters, contributions welcome!
diff --git a/config.json b/config.json @@ -0,0 +1,10 @@ +{ + "ip": "192.168.178.123", + "password": "your registration number", + "mqtt": { + "host": "127.0.0.1", + "port": 1883, + "topic": "solax2mqtt" + }, + "updateInterval": 10 +}+ \ No newline at end of file
diff --git a/flake.lock b/flake.lock @@ -0,0 +1,43 @@ +{ + "nodes": { + "flake-utils": { + "locked": { + "lastModified": 1667395993, + "narHash": "sha256-nuEHfE/LcWyuSWnS8t12N1wc105Qtau+/OdUAjtQ0rA=", + "owner": "numtide", + "repo": "flake-utils", + "rev": "5aed5285a952e0b949eb3ba02c12fa4fcfef535f", + "type": "github" + }, + "original": { + "owner": "numtide", + "repo": "flake-utils", + "type": "github" + } + }, + "nixpkgs": { + "locked": { + "lastModified": 1670543317, + "narHash": "sha256-4mMR56rtxKr+Gwz399jFr4i76SQZxsLWxxyfQlPXRm0=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "7a6a010c3a1d00f8470a5ca888f2f927f1860a19", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixos-22.11", + "repo": "nixpkgs", + "type": "github" + } + }, + "root": { + "inputs": { + "flake-utils": "flake-utils", + "nixpkgs": "nixpkgs" + } + } + }, + "root": "root", + "version": 7 +}
diff --git a/flake.nix b/flake.nix @@ -0,0 +1,54 @@ +{ + description = "Exporter for solax solar inverters to mqtt, written in nim"; + + inputs = { + flake-utils.url = "github:numtide/flake-utils"; + nixpkgs.url = "github:NixOS/nixpkgs/nixos-22.11"; + }; + + outputs = { self, nixpkgs, flake-utils }: { + + overlay = final: prev: { + + solax2mqtt = ( + let + nmqtt = final.fetchFromGitHub { + owner = "zevv"; + repo = "nmqtt"; + rev = "v1.0.4"; + sha256 = "1by0xyqz754dny19lf8rpkg42passnj0rs6rk3jr763m1zr803mc"; + }; + + in final.nimPackages.buildNimPackage { + name = "solax2mqtt"; + src = self; + + buildInputs = [ nmqtt ]; + + nimBinOnly = true; + nimRelease = true; + } + ); + + }; + + } // (flake-utils.lib.eachDefaultSystem (system: + let + pkgs = import nixpkgs { + inherit system; + overlays = [ self.overlay ]; + }; + + in rec { + + packages.default = pkgs.solax2mqtt; + packages.solax2mqtt = pkgs.solax2mqtt; + + apps.default = { + type = "app"; + program = "${pkgs.solax2mqtt}/bin/solax2mqtt"; + }; + + } + )); +}+ \ No newline at end of file
diff --git a/solax2mqtt.nimble b/solax2mqtt.nimble @@ -0,0 +1,15 @@ +# Package + +version = "0.1.0" +author = "Leah(ctucx)" +description = "Exports data from solax solar inverters to mqtt" +license = "AGPL-3.0" +srcDir = "./src" +bin = @["solax2mqtt"] + + + +# Dependencies + +requires "nim >= 0.20.0" +requires "nmqtt == 1.0.4"+ \ No newline at end of file
diff --git a/src/solax.nim b/src/solax.nim @@ -0,0 +1,293 @@ +import std/asyncdispatch +import std/[httpclient, times] +import std/[tables, math, json] +import utils + +type Operator = enum + div10, div100, none, + read16BitSignedDiv10, read16BitSignedDiv100, read16BitSignedNone + read32BitUnsignedDiv10, read32BitUnsignedDiv100, read32BitUnsignedNone + read32BitSignedDiv10 , read32BitSignedDiv100, read32BitSignedNone + +var inverterModel = { + 14: "X3-Hybrid G4", + 15: "X1-Hybrid G4", + 23: "X1-Hybrid G5" + }.toTable + +var inverterMode = { + 0: "Waiting", + 1: "Checking", + 2: "Normal", + 3: "Off", + 4: "Permanent Failure", + 5: "Updating", + 6: "EPS Check", + 7: "EPS Mode", + 8: "Self Test", + 9: "Idle", + 10: "Standby" + }.toTable + +var batteryMode = { + 0: "Self Use Mode", + 1: "Force Time use", + 2: "Back Up Mode", + 3: "Feed-in Priority" + }.toTable + +var batteryStatus = { + 0: "Failure", + 1: "OK" + }.toTable + +proc convert (json: JsonNode, operator: Operator, a: int, b:int = 0): JsonNode = + result = case operator + of div10: + newJFloat(utils.round(json["Data"][a].getInt / 10, 1)) + of div100: + newJFloat(utils.round(json["Data"][a].getInt / 100, 2)) + of none: + json["Data"][a] + + of read16BitSignedDiv10: + newJFloat(utils.round( + read16BitSigned( + json["Data"][a].getInt + ) / 10 + , 1)) + of read16BitSignedDiv100: + newJFloat(utils.round( + read16BitSigned( + json["Data"][a].getInt + ) / 100 + , 2)) + of read16BitSignedNone: + newJInt( + read16BitSigned( + json["Data"][a].getInt + ) + ) + + of read32BitUnsignedDiv10: + newJFloat(utils.round( + read32BitUnsigned( + json["Data"][a].getInt, + json["Data"][b].getInt + ).float64 / 10.float64 + , 1)) + of read32BitUnsignedDiv100: + newJFloat(utils.round( + read32BitUnsigned( + json["Data"][a].getInt, + json["Data"][b].getInt + ).float64 / 100.float64 + , 2)) + of read32BitUnsignedNone: + newJInt( + read32BitUnsigned( + json["Data"][a].getInt, + json["Data"][b].getInt + ) + ) + + + of read32BitSignedDiv10: + newJFloat(utils.round( + read32BitSigned( + json["Data"][a].getInt, + json["Data"][b].getInt + ).float64 / 10.float64 + , 1)) + of read32BitSignedDiv100: + newJFloat(utils.round( + read32BitSigned( + json["Data"][a].getInt, + json["Data"][b].getInt + ).float64 / 100.float64 + , 2)) + of read32BitSignedNone: + newJInt( + read32BitSigned( + json["Data"][a].getInt, + json["Data"][b].getInt + ) + ) + +proc parseInverterData* (json: JsonNode): JsonNode = + result = newJObject() + result["model"] = newJString(inverterModel[json["type"].getInt]) + result["registration_number"] = json["sn"] + + case json["type"].getInt: + # X3-Hybrid G4 + of 14: + result["serial_number"] = json["Information"][2] + result["firmware_version"] = json["ver"] + result["mode"] = newJString(inverterMode[json["Data"][19].getInt]) + + # volts + result["l1_voltage"] = convert(json, div10, 0) + result["l2_voltage"] = convert(json, div10, 1) + result["l3_voltage"] = convert(json, div10, 2) + result["eps_l1_voltage"] = convert(json, div10, 23) + result["eps_l2_voltage"] = convert(json, div10, 23) + result["eps_l3_voltage"] = convert(json, div10, 25) + result["pv1_voltage"] = convert(json, div10, 10) + result["pv2_voltage"] = convert(json, div10, 11) + + # amps + result["l1_current"] = convert(json, div10, 3) + result["l2_current"] = convert(json, div10, 4) + result["l3_current"] = convert(json, div10, 5) + result["eps_l1_current"] = convert(json, div10, 26) + result["eps_l2_current"] = convert(json, div10, 27) + result["eps_l3_current"] = convert(json, div10, 28) + result["pv1_current"] = convert(json, div10, 12) + result["pv2_current"] = convert(json, div10, 13) + + # watts + result["l1_power"] = convert(json, none, 6) + result["l2_power"] = convert(json, none, 7) + result["l3_power"] = convert(json, none, 8) + result["eps_l1_power"] = convert(json, none, 29) + result["eps_l2_power"] = convert(json, none, 30) + result["eps_l3_power"] = convert(json, none, 31) + result["power"] = convert(json, none, 9) + result["pv1_power"] = convert(json, none, 14) + result["pv2_power"] = convert(json, none, 15) + result["pv_power"] = newJInt(json["Data"][14].getInt + json["Data"][15].getInt) + result["grid_in_power"] = convert(json, read32BitSignedNone, 34, 35) + result["power_total"] = convert(json, read16BitSignedNone, 47) + + # hz + result["l1_frequency"] = convert(json, div100, 16) + result["l2_frequency"] = convert(json, div100, 17) + result["l3_frequency"] = convert(json, div100, 18) + + # kwh + result["yield_energy_today"] = convert(json, div10, 70) + result["yield_energy_total"] = convert(json, read32BitUnsignedDiv10, 68, 69) + result["grid_out_energy_today"] = convert(json, div100, 92) + result["grid_in_energy_today"] = convert(json, div100, 90) + result["grid_in_energy_total"] = convert(json, read32BitUnsignedDiv100, 86, 87) + result["energy_total"] = convert(json, read32BitUnsignedDiv100, 88, 89) + + # X4-Hybrid G4 + # X4-Hybrid G5 + of 15, 23: + result["serial_number"] = json["Information"][2] + result["firmware_version"] = json["ver"] + result["mode"] = newJString(inverterMode[json["Data"][10].getInt]) + + # volts + result["l1_voltage"] = convert(json, div10, 0) + result["eps_l1_voltage"] = convert(json, div10, 0) + result["pv1_voltage"] = convert(json, div10, 4) + result["pv2_voltage"] = convert(json, div10, 5) + + # amps + result["l1_current"] = convert(json, read16BitSignedDiv10, 1) + result["eps_l1_current"] = convert(json, read16BitSignedDiv10, 1) + result["pv1_current"] = convert(json, div10, 6) + result["pv2_current"] = convert(json, div10, 7) + + # watts + result["l1_power"] = convert(json, none, 2) + result["epsl1_power"] = convert(json, none, 2) + result["total_power"] = convert(json, none, 2) + result["pv1_power"] = convert(json, none, 8) + result["pv2_power"] = convert(json, none, 9) + result["pv_power"] = newJInt(json["Data"][8].getInt + json["Data"][9].getInt) + result["grid_in_power"] = convert(json, read32BitSignedNone, 32, 33) + + # hz + result["l1_frequency"] = convert(json, div100, 3) + + # kwh + result["grid_in_energy"] = convert(json, read32BitUnsignedDiv100, 34, 35) + result["yield_energy_today"] = convert(json, div10, 13) + result["yield_energy_total"] = convert(json, read32BitUnsignedDiv10, 11, 12) + result["energy_total"] = convert(json, read32BitUnsignedDiv100, 36, 37) + + else: + result["error"] = newJString("Unknown model") + +proc parseBatteryData* (json: JsonNode): JsonNode = + result = newJObject() + + case json["type"].getInt: + # X3-Hybrid G4 + of 14: + result["mode"] = newJString(batteryMode[json["Data"][168].getInt]) + result["status"] = newJString(batteryStatus[json["Data"][45].getInt]) + result["temperature"] = convert(json, read16BitSignedNone, 105) + + # volts + result["voltage"] = convert(json, div100, 39) + + # amps + result["current"] = convert(json, read16BitSignedDiv100, 40) + + # watts + result["power"] = convert(json, read16BitSignedNone, 41) + + # percent + result["soc"] = convert(json, none, 103) + + # kwh + result["remaining_capacity"] = convert(json, div10, 106) + + # kwh + result["discharge_today"] = convert(json, div10, 78) + result["charge_today"] = convert(json, div10, 79) + result["discharge_total"] = convert(json, read32BitUnsignedDiv10, 74, 75) + result["charge_total"] = convert(json, read32BitUnsignedDiv10, 76, 77) + + # X1-Hybrid G4 + # X1-Hybrid G5 + of 15, 23: + result["temperature"] = convert(json, read16BitSignedNone, 17) + + # volts + result["voltage"] = convert(json, div100, 14) + + # amps + result["current"] = convert(json, div100, 15) + + # watts + result["power"] = convert(json, none, 16) + + # percent + result["soc"] = convert(json, none, 18) + + # kwh + result["remaining_capacity"] = convert(json, div10, 106) + + else: + result["error"] = newJString("Unknown model") + +proc getSolaxData* (ip: string, password: string): Future[JsonNode] {.async.} = + result = newJObject() + + result["last_update"] = newJInt(now().toTime.toUnix) + + try: + let client = newAsyncHttpClient() + let body = "optType=ReadRealTimeData&pwd=" & password + + let response = await client.postContent("http://" & ip, body) + let json = parseJson(response) + + client.close() + + for key, value in parseInverterData(json): + result["inverter_" & key] = value + + for key, value in parseBatteryData(json): + result["battery_" & key] = value + + except: + echo "Error[getSolaxData]:\n" & getCurrentExceptionMsg() + result["error"] = newJString(getCurrentExceptionMsg()) + + \ No newline at end of file
diff --git a/src/solax2mqtt.nim b/src/solax2mqtt.nim @@ -0,0 +1,69 @@ +import std/[asyncdispatch] +import std/[os, posix] +import std/[tables, json, options] +import std/math +import nmqtt + +import types, solax + +var mqttContext* {.threadvar.} : MqttCtx + +proc ctrlCHook* () {.noconv.} = + echo "Ctrl+C fired! \nStopping Server now!" + waitFor mqttContext.disconnect() + quit() + + +proc updateData (config: Config) {.async.} = + await sleepAsync(500) + + while true: + try: + let solaxData = await getSolaxData(config.ip, config.password) + + if mqttContext.isConnected: + await mqttContext.publish(config.mqtt.topic, $solaxData, 2, true) + + for key, value in solaxData: + await sleepAsync(250) + await mqttContext.publish(config.mqtt.topic & "/" & key, $value, 2, true) + + except: + echo "Error[updateData]:\n", getCurrentExceptionMsg() + + await sleepAsync(int(config.updateInterval * 1000)) + + +proc main () {.async.} = + setControlCHook(ctrlCHook) + + onSignal(SIGTERM): + echo "Got SIGTERM! \nStopping Server now!" + waitFor mqttContext.disconnect() + quit() + + var configFile = "./config.json" + + if getEnv("CONFIG_PATH") != "": + configFile = getEnv("CONFIG_PATH") + + if not fileExists(configFile): + echo "Config file not found" + quit() + + let config = parseFile(configFile).to(Config) + + mqttContext = newMqttCtx("solax2mqtt") + mqttContext.set_host(config.mqtt.host, config.mqtt.port) + mqttContext.set_verbosity(1) + + if (config.mqtt.username.isSome and config.mqtt.password.isSome): + mqttContext.set_auth(config.mqtt.username.get, config.mqtt.password.get) + + await mqttContext.start() + + asyncCheck updateData(config) + + runForever() + +waitFor main()+ \ No newline at end of file
diff --git a/src/types.nim b/src/types.nim @@ -0,0 +1,14 @@ +import std/options + +type MqttConfig* = object + host*: string + port*: int + topic*: string + username*: Option[string] + password*: Option[string] + +type Config* = object + ip*: string + password*: string + mqtt*: MqttConfig + updateInterval*: int
diff --git a/src/utils.nim b/src/utils.nim @@ -0,0 +1,24 @@ +import std/httpclient +import std/[tables, math, json] + +proc round* [T: float32|float64](value: T, places: int = 0): float = + if places == 0: + result = round(value) + else: + result = value * pow(10.0, T(places)) + result = floor(result) + result = result / pow(10.0, T(places)) + +proc read8BitUnsigned* (n: int): int = + return n mod 256 + +proc read16BitSigned* (n: int): int = + if n < 32768: return n + else: return n - 65536 + +proc read32BitUnsigned* (a: int, b: int): int64 = + return 65536 * b + a; + +proc read32BitSigned* (a: int, b: int): int64 = + if a < 32768: return a + 65536 * b + else: return a + 65536 * b - 4294967296