From 6a7f0d727d74b46afb7e978fe8cfa5f12c2f76fd Mon Sep 17 00:00:00 2001 From: Thomas Klaehn Date: Wed, 9 Nov 2022 08:57:56 +0100 Subject: [PATCH] Initial commit Signed-off-by: Thomas Klaehn --- .gitignore | 1 + .vscode/launch.json | 16 ++++ .vscode/tasks.json | 16 ++++ Makefile | 48 ++++++++++++ README.md | 34 ++++++++ config/config.json | 9 +++ go.mod | 18 +++++ go.sum | 30 +++++++ main.go | 181 +++++++++++++++++++++++++++++++++++++++++++ modbus.go | 91 ++++++++++++++++++++++ powercollect.service | 10 +++ 11 files changed, 454 insertions(+) create mode 100644 .gitignore create mode 100644 .vscode/launch.json create mode 100644 .vscode/tasks.json create mode 100644 Makefile create mode 100644 README.md create mode 100644 config/config.json create mode 100644 go.mod create mode 100644 go.sum create mode 100644 main.go create mode 100644 modbus.go create mode 100644 powercollect.service 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..e3c71e9 --- /dev/null +++ b/README.md @@ -0,0 +1,34 @@ +# Powercollect + +Collect power data pf 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 install +``` + +## Configuration + +Default configuration file is installed in `/etc/powercollect/config.json` 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