commit 6d9b0da10a171d7614ab2dd1d18fd44dddbfaf31
Author: Thomas Klaehn <thomas.klaehn@perinet.io>
Date:   Wed Nov 9 08:57:56 2022 +0100

    Initial commit
    
    Signed-off-by: Thomas Klaehn <thomas.klaehn@perinet.io>

diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..e660fd9
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1 @@
+bin/
diff --git a/.vscode/launch.json b/.vscode/launch.json
new file mode 100644
index 0000000..599bdf5
--- /dev/null
+++ b/.vscode/launch.json
@@ -0,0 +1,16 @@
+{
+    // Use IntelliSense to learn about possible attributes.
+    // Hover to view descriptions of existing attributes.
+    // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
+    "version": "0.2.0",
+    "configurations": [
+        {
+            "name": "Launch Package",
+            "type": "go",
+            "request": "launch",
+            "mode": "auto",
+            "program": "${fileDirname}",
+            "args": ["-c", "/etc/powercollect/config.json"]
+        }
+    ]
+}
\ No newline at end of file
diff --git a/.vscode/tasks.json b/.vscode/tasks.json
new file mode 100644
index 0000000..10213ea
--- /dev/null
+++ b/.vscode/tasks.json
@@ -0,0 +1,16 @@
+{
+    // See https://go.microsoft.com/fwlink/?LinkId=733558
+    // for the documentation about the tasks.json format
+    "version": "2.0.0",
+    "tasks": [
+        {
+            "label": "Go build",
+            "type": "shell",
+            "command": "make all",
+            "group": {
+                "kind": "build",
+                "isDefault": true
+            }
+        }
+    ]
+}
\ No newline at end of file
diff --git a/Makefile b/Makefile
new file mode 100644
index 0000000..fc6620a
--- /dev/null
+++ b/Makefile
@@ -0,0 +1,48 @@
+PROJECT_NAME := powercollect
+
+PREFIX ?= /usr/bin
+
+CONFIG_DIR := /etc/$(PROJECT_NAME)
+SYSTEM_DIR := /usr/lib/systemd/system
+
+CONFIG_FILE := config/config.json
+BIN_FILE := bin/$(PROJECT_NAME)
+UNIT_FILE := powercollect.service
+README_FILE := README.md
+
+.PHONY: all
+all:
+	mkdir -p bin
+	go clean
+	go mod tidy
+	go build -o $(BIN_FILE)
+
+.PHONY: clean
+clean:
+	go clean
+	rm -rf bin
+
+.PHONY: install
+install: all
+	@if [ -f $(CONFIG_DIR)/$(notdir $(CONFIG_FILE)) ]; then \
+		echo "$(CONFIG_DIR)/$(notdir $(CONFIG_FILE)) already exists - skipping..."; \
+	else \
+		install -d $(CONFIG_DIR); \
+		install -m 0644 $(CONFIG_FILE) $(CONFIG_DIR); \
+		echo "install -d $(CONFIG_DIR)"; \
+		echo "install -m 0644 $(CONFIG_FILE) $(CONFIG_DIR)"; \
+	fi
+	install -d $(PREFIX)
+	install -m 0755 $(BIN_FILE) $(PREFIX)
+	install -d $(SYSTEM_DIR)
+	install -m 0644 $(UNIT_FILE) $(SYSTEM_DIR)
+
+.PHONY: uninstall
+uninstall:
+	rm -rf $(CONFIG_DIR)
+	rm -rf $(SYSTEM_DIR)/$(UNIT_FILE)
+	rm -rf $(PREFIX)/$(PROJECT_NAME)
+
+.PHONY: package
+package: all
+	tar cvzf $(PROJECT_NAME).tar.gz $(CONFIG_FILE) $(BIN_FILE) $(UNIT_FILE) $(README_FILE)
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..709ab27
--- /dev/null
+++ b/README.md
@@ -0,0 +1,41 @@
+# Powercollect
+
+Collect power data of Eastron SDM-630 power meter over modbus-tcp and store
+them in InfluxDB
+
+## Installation
+
+```shell
+make install
+```
+
+Default install location for the executable is `/usr/bin`. This can be
+modyfied by changing the `PREFIX` variable.
+
+```shell
+PREFIX=/usr/local/bin make install
+```
+
+## Uninstallation
+
+```shell
+make uninstall
+```
+
+When `PREFIX` was modyfied for installation it needs to be changed for
+uninstalling as well.
+
+```shell
+PREFIX=/usr/local/bin make uninstall
+```
+
+## Configuration
+
+Default configuration file is installed in `/etc/powercollect/config.json`
+
+## systemd service
+
+```shell
+systemctl enable powercollect.service
+systemctl start powercollect.service
+```
diff --git a/config/config.json b/config/config.json
new file mode 100644
index 0000000..f2ac1ea
--- /dev/null
+++ b/config/config.json
@@ -0,0 +1,9 @@
+{
+    "rtu_address": 1,
+    "sample_rate": 10,
+    "modbus_host": "wels.local",
+    "modbus_port": 502,
+    "influxdb_host": "barsch.local",
+    "influxdb_port": 8086,
+    "influxdb_token": ""
+}
diff --git a/go.mod b/go.mod
new file mode 100644
index 0000000..11cac0c
--- /dev/null
+++ b/go.mod
@@ -0,0 +1,18 @@
+module powercollect
+
+go 1.19
+
+require (
+	actshad.dev/modbus v0.2.0
+	github.com/influxdata/influxdb-client-go/v2 v2.12.0
+)
+
+require (
+	github.com/apapsch/go-jsonmerge/v2 v2.0.0 // indirect
+	github.com/deepmap/oapi-codegen v1.12.2 // indirect
+	github.com/goburrow/serial v0.1.0 // indirect
+	github.com/google/uuid v1.3.0 // indirect
+	github.com/influxdata/line-protocol v0.0.0-20210922203350-b1ad95c89adf // indirect
+	github.com/pkg/errors v0.9.1 // indirect
+	golang.org/x/net v0.1.0 // indirect
+)
diff --git a/go.sum b/go.sum
new file mode 100644
index 0000000..aecc3ba
--- /dev/null
+++ b/go.sum
@@ -0,0 +1,30 @@
+actshad.dev/modbus v0.2.0 h1:saCi1iAZOHlmhDiFAY2a8fjouS63mWQVoVVhitEkFFQ=
+actshad.dev/modbus v0.2.0/go.mod h1:8IHuLxLYjoX4NomWdAJkEOsAVJnncMAoppE7wRlIYME=
+github.com/RaveNoX/go-jsoncommentstrip v1.0.0/go.mod h1:78ihd09MekBnJnxpICcwzCMzGrKSKYe4AqU6PDYYpjk=
+github.com/apapsch/go-jsonmerge/v2 v2.0.0 h1:axGnT1gRIfimI7gJifB699GoE/oq+F2MU7Dml6nw9rQ=
+github.com/apapsch/go-jsonmerge/v2 v2.0.0/go.mod h1:lvDnEdqiQrp0O42VQGgmlKpxL1AP2+08jFMw88y4klk=
+github.com/bmatcuk/doublestar v1.1.1/go.mod h1:UD6OnuiIn0yFxxA2le/rnRU1G4RaI4UvFv1sNto9p6w=
+github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
+github.com/deepmap/oapi-codegen v1.12.2 h1:F7SMEn0UMpJV6kWwDYqfDmnnOYHIcU7ETV8qTVFdyI0=
+github.com/deepmap/oapi-codegen v1.12.2/go.mod h1:ao2aFwsl/muMHbez870+KelJ1yusV01RznwAFFrVjDc=
+github.com/goburrow/serial v0.1.0 h1:v2T1SQa/dlUqQiYIT8+Cu7YolfqAi3K96UmhwYyuSrA=
+github.com/goburrow/serial v0.1.0/go.mod h1:sAiqG0nRVswsm1C97xsttiYCzSLBmUZ/VSlVLZJ8haA=
+github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I=
+github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
+github.com/influxdata/influxdb-client-go/v2 v2.12.0 h1:LGct9uIp36IT+8RAJdmJGQbNonGi26YfYYSpDIyq8fI=
+github.com/influxdata/influxdb-client-go/v2 v2.12.0/go.mod h1:YteV91FiQxRdccyJ2cHvj2f/5sq4y4Njqu1fQzsQCOU=
+github.com/influxdata/line-protocol v0.0.0-20210922203350-b1ad95c89adf h1:7JTmneyiNEwVBOHSjoMxiWAqB992atOeepeFYegn5RU=
+github.com/influxdata/line-protocol v0.0.0-20210922203350-b1ad95c89adf/go.mod h1:xaLFMmpvUxqXtVkUJfg9QmT88cDaCJ3ZKgdZ78oO8Qo=
+github.com/juju/gnuflag v0.0.0-20171113085948-2ce1bb71843d/go.mod h1:2PavIy+JPciBPrBUjwbNvtwB6RQlve+hkpll6QSNmOE=
+github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
+github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
+github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
+github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
+github.com/spkg/bom v0.0.0-20160624110644-59b7046e48ad/go.mod h1:qLr4V1qq6nMqFKkMo8ZTx3f+BZEkzsRUY10Xsm2mwU0=
+github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
+github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
+github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk=
+golang.org/x/net v0.1.0 h1:hZ/3BUoy5aId7sCpA/Tc5lt8DkFgdVS2onTpJsZ/fl0=
+golang.org/x/net v0.1.0/go.mod h1:Cx3nUiGt4eDBEyega/BKRp+/AlGL8hYe7U9odMt2Cco=
+gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
diff --git a/main.go b/main.go
new file mode 100644
index 0000000..5c6b09a
--- /dev/null
+++ b/main.go
@@ -0,0 +1,181 @@
+package main
+
+import (
+	"context"
+	"encoding/json"
+	"flag"
+	"log"
+	"os"
+	"strconv"
+	"sync"
+	"time"
+
+	influxdb2 "github.com/influxdata/influxdb-client-go/v2"
+)
+
+type sdm630Register struct {
+	Address uint16
+	Unit    string
+}
+
+type measure struct {
+	Value float32 `json:"value"`
+	Unit  string  `json:"unit"`
+}
+
+type sampleCache struct {
+	sample map[string]measure
+	mutex  sync.Mutex
+}
+
+type config struct {
+	RtuAddress    int    `json:"rtu_address"`
+	SampleRate    int    `json:"sample_rate"`
+	ModbusHost    string `json:"modbus_host"`
+	ModbusPort    int    `json:"modbus_port"`
+	InfluxdbHost  string `json:"influxdb_host"`
+	InfluxdbPort  int    `json:"influxdb_port"`
+	InfluxdbToken string `json:"influxdb_token"`
+}
+
+var (
+	// SDM630 input registers
+	sdm_registers = map[string]sdm630Register{
+		"L1Voltage":    {0x00, "V"},
+		"L2Voltage":    {0x02, "V"},
+		"L3Voltage":    {0x04, "V"},
+		"L1Current":    {0x06, "A"},
+		"L2Current":    {0x08, "A"},
+		"L3Current":    {0x0A, "A"},
+		"L1PowerW":     {0x0C, "W"},
+		"L2PowerW":     {0x0E, "W"},
+		"L3PowerW":     {0x10, "W"},
+		"L1PhaseAngle": {0x24, "°"},
+		"L2PhaseAngle": {0x26, "°"},
+		"L3PhaseAngle": {0x28, "°"},
+		"TotalImport":  {0x48, "kWh"},
+		"TotalExport":  {0x4A, "kWh"},
+	}
+
+	logger            log.Logger = *log.Default()
+	last_total_import float32    = 0.0
+	last_total_export float32    = 0.0
+	config_path       string
+	config_cache      = config{
+		RtuAddress:    1,    // modbus device slave address (0x01)
+		SampleRate:    1,    // sec
+		ModbusHost:    "",   // hostname of the modbus tcp to rtu bridge
+		ModbusPort:    502,  // port of the modbus tcp to rtu bridge
+		InfluxdbHost:  "",   // hostname of the influxdb server
+		InfluxdbPort:  8086, // port of the influxdb server
+		InfluxdbToken: "",   // access token for the influx db
+	}
+)
+
+func read_config() {
+	data, err := os.ReadFile(config_path)
+	if err != nil {
+		logger.Printf("Unable to read %s", config_path)
+		return
+	}
+	err = json.Unmarshal(data, &config_cache)
+	if err != nil {
+		logger.Print("Unable to evaluate config data")
+		return
+	}
+}
+
+func init() {
+	logger.SetPrefix("PowerMeterSDM630: ")
+	logger.Println("Starting")
+
+}
+
+func sample_is_valid(sample map[string]measure) bool {
+	if len(sample) == 0 {
+		logger.Print("Sample invalid - contains no data")
+		return false
+	}
+
+	result := float32(0.0)
+	// sample is invalid when all values equals 0.0
+	for _, value := range sample {
+		result += value.Value
+	}
+	if result == 0.0 {
+		logger.Print("Sample invalid - all values equals 0.0")
+		return false
+	}
+
+	// total import and total export can never decrease
+	for key, value := range sample {
+		if key == "TotalImport" {
+			if value.Value < last_total_import {
+				logger.Printf("Sample invalid - Total import lower than previous one (%f vs. %f)", value.Value, last_total_import)
+				return false
+			}
+			last_total_import = value.Value
+		}
+		if key == "TotalExport" {
+			if value.Value < last_total_export {
+				logger.Printf("Sample invalid - Total export lower than previous one (%f vs. %f)", value.Value, last_total_export)
+				return false
+			}
+			last_total_export = value.Value
+		}
+	}
+	return true
+}
+
+func main() {
+	flag.StringVar(&config_path, "c", "./config/config.json", "Specify path to find the config file. Default is ./config/config.json")
+	flag.Parse()
+	read_config()
+
+	go keep_mbus_connection()
+	go collect_mbus_samples()
+
+	influxdb_url := "http://" + config_cache.InfluxdbHost + ":" + strconv.Itoa(config_cache.InfluxdbPort)
+	client := influxdb2.NewClient(influxdb_url, config_cache.InfluxdbToken)
+	// always close client at the end
+	defer client.Close()
+
+	// get non-blocking write client
+	writeAPI := client.WriteAPIBlocking("tkl", "home")
+
+	ctx := context.Background()
+
+	for {
+		time.Sleep(time.Duration(config_cache.SampleRate * 1000000000))
+
+		sample_cache.mutex.Lock()
+		sample := sample_cache.sample
+		sample_cache.mutex.Unlock()
+
+		if sample_is_valid(sample) {
+			point := influxdb2.NewPointWithMeasurement("power")
+			point.AddTag("sensor", "powermeter")
+			for key, value := range sample {
+				point.AddField(key, value.Value)
+				point.AddField("unit", value.Unit)
+			}
+
+			// calculate TotalPowerW
+			total_power := sample["L1PowerW"].Value + sample["L2PowerW"].Value + sample["L3PowerW"].Value
+			point.AddField("TotalPowerW", total_power)
+			point.AddField("unit", "W")
+
+			point.SetTime(time.Now())
+			err := writeAPI.WritePoint(ctx, point)
+			if err != nil {
+				logger.Print(err.Error())
+				continue
+			}
+			err = writeAPI.Flush(ctx)
+			if err != nil {
+				logger.Print(err.Error())
+				continue
+			}
+		}
+	}
+}
diff --git a/modbus.go b/modbus.go
new file mode 100644
index 0000000..23a53fa
--- /dev/null
+++ b/modbus.go
@@ -0,0 +1,91 @@
+package main
+
+import (
+	"math"
+	"strconv"
+	"time"
+
+	"actshad.dev/modbus"
+)
+
+var (
+	modbus_client modbus.Client
+	sample_cache  sampleCache
+)
+
+func modbus_is_connected() bool {
+	return modbus_client != nil
+}
+
+// Read modbus register and convert result into float
+func modbus_read_float_register(register_address uint16) (float32, error) {
+	var result float32 = 0.0
+	if modbus_client != nil {
+		res, err := modbus_client.ReadInputRegisters(register_address, 2)
+		if err != nil {
+			logger.Print("Modbus connection lost")
+			modbus_client = nil
+			return 0.0, err
+		}
+		var ieee754 uint32 = (uint32(res[0])<<24 + (uint32(res[1]) << 16) + (uint32(res[2]) << 8) + uint32(res[3]))
+		result = math.Float32frombits(ieee754)
+	}
+	return result, nil
+}
+
+// Connect to modbus tcp bridge
+func modbus_connect(host string, port uint, slave_id byte) error {
+	connection := host + ":" + strconv.FormatUint(uint64(port), 10)
+	handler := modbus.NewTCPClientHandler(connection)
+	handler.Timeout = 10 * time.Second
+	handler.SlaveId = slave_id
+
+	err := handler.Connect()
+	if err != nil {
+		logger.Print(err)
+		return err
+	}
+	logger.Print("Modbus connected")
+
+	defer handler.Close()
+	modbus_client = modbus.NewClient(handler)
+	return nil
+}
+
+// connect to modbus tcp bridge host. If connection is lost try to re-connect.
+func keep_mbus_connection() {
+
+	for { // ever
+		time.Sleep(time.Second)
+		if !modbus_is_connected() {
+			modbus_connect(config_cache.ModbusHost,
+				uint(config_cache.ModbusPort),
+				byte(config_cache.RtuAddress),
+			)
+		}
+	}
+}
+
+// Collect data of SDM-630 power meter
+func collect_mbus_samples() {
+	for { // ever
+		if modbus_is_connected() {
+			sample := make(map[string]measure)
+			for key, register := range sdm_registers {
+				res, err := modbus_read_float_register(register.Address)
+				if err != nil {
+					logger.Printf("Could not read from %s (%s)", key, err)
+					continue
+				}
+				var mes measure
+				mes.Value = res
+				mes.Unit = register.Unit
+				sample[key] = mes
+			}
+			sample_cache.mutex.Lock()
+			sample_cache.sample = sample
+			sample_cache.mutex.Unlock()
+		}
+		time.Sleep(time.Duration(config_cache.SampleRate * 1000000000))
+	}
+}
diff --git a/powercollect.service b/powercollect.service
new file mode 100644
index 0000000..db9072c
--- /dev/null
+++ b/powercollect.service
@@ -0,0 +1,10 @@
+[Unit]
+Description=SDM630 power collect service
+After=multi-user.target
+
+[Service]
+Type=idle
+ExecStart=/usr/bin/powercollect -c /etc/powercollect/config.json
+
+[Install]
+WantedBy=multi-user.target