ctucx.git: solax2mqtt

Publish data from Solax solar inverters via MQTT

commit c8a112bbd143652b80839859adeec2d0e7f91b14
Author: Leah (ctucx) <git@ctu.cx>
Date: Thu, 4 May 2023 21:41:25 +0200

initial commit
10 files changed, 537 insertions(+), 0 deletions(-)
A
.gitignore
|
1
+
A
README.md
|
9
+++++++++
A
config.json
|
11
+++++++++++
A
flake.lock
|
43
+++++++++++++++++++++++++++++++++++++++++++
A
flake.nix
|
55
+++++++++++++++++++++++++++++++++++++++++++++++++++++++
A
solax2mqtt.nimble
|
16
++++++++++++++++
A
src/solax.nim
|
294
+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
A
src/solax2mqtt.nim
|
70
++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
A
src/types.nim
|
14
++++++++++++++
A
src/utils.nim
|
24
++++++++++++++++++++++++
diff --git a/.gitignore b/.gitignore
@@ -0,0 +1 @@
+result
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