commit 5a170736d613e7a6f743fa8dac0f69435fdcdb00 Author: Thomas Klaehn Date: Wed Oct 1 09:38:42 2025 +0200 Initial commit Signed-off-by: Thomas Klaehn diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..332ace8 --- /dev/null +++ b/Makefile @@ -0,0 +1,46 @@ +PROJECT_NAME := pvcollect + +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 := pvcollect.service +README_FILE := README.md + +.PHONY: all +all: + mkdir -p bin + go build -buildvcs=false -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/bin/pvcollect b/bin/pvcollect new file mode 100755 index 0000000..4ac532e Binary files /dev/null and b/bin/pvcollect differ diff --git a/config/config.json b/config/config.json new file mode 100644 index 0000000..13100d2 --- /dev/null +++ b/config/config.json @@ -0,0 +1,92 @@ +{ + "sample_rate": 10, + "influxdb_host": "p5.local", + "influxdb_port": 8086, + "influxdb_token": "", + "devices": [ + { + "name": "AlphaEss", + "type": "pv", + "modbus": { + "protocol": "tcp", + "host": "192.168.178.79", + "port": 502, + "slave_address": 85 + }, + "registers": [ + { + "name": "TotalEnergyFeedToGridGrid", + "type": "holding", + "address": 16, + "quantity": 2, + "factor": 0.01, + "unit": "kWh" + }, + { + "name": "TotalEnergyConsumeFromGridGrid", + "type": "holding", + "address": 18, + "quantity": 2, + "factor": 0.01, + "unit": "kWh" + }, + { + "name": "Pv1Power", + "type": "holding", + "address": 1055, + "quantity": 2, + "factor": 1, + "unit": "W" + }, + { + "name": "Pv2Power", + "type": "holding", + "address": 1059, + "quantity": 2, + "factor": 1, + "unit": "W" + }, + { + "name": "InverterTotalPvEnergy", + "type": "holding", + "address": 1086, + "quantity": 2, + "factor": 0.1, + "unit": "kWh" + }, + { + "name": "InverterPowerL1", + "type": "holding", + "address": 1030, + "quantity": 2, + "factor": 1, + "unit": "W" + }, + { + "name": "InverterPowerL2", + "type": "holding", + "address": 1032, + "quantity": 2, + "factor": 1, + "unit": "W" + }, + { + "name": "InverterPowerL3", + "type": "holding", + "address": 1034, + "quantity": 2, + "factor": 1, + "unit": "W" + }, + { + "name": "BatteryStateOfCharge", + "type": "holding", + "address": 258, + "quantity": 1, + "factor": 0.1, + "unit": "%" + } + ] + } + ] +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..1e323ef --- /dev/null +++ b/go.mod @@ -0,0 +1,17 @@ +module pvcollect + +go 1.24.5 + +require ( + github.com/influxdata/influxdb-client-go/v2 v2.14.0 + github.com/simonvetter/modbus v1.6.3 +) + +require ( + github.com/apapsch/go-jsonmerge/v2 v2.0.0 // indirect + github.com/goburrow/serial v0.1.0 // indirect + github.com/google/uuid v1.3.1 // indirect + github.com/influxdata/line-protocol v0.0.0-20200327222509-2487e7298839 // indirect + github.com/oapi-codegen/runtime v1.0.0 // indirect + golang.org/x/net v0.23.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..284bdd0 --- /dev/null +++ b/go.sum @@ -0,0 +1,31 @@ +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/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +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.1 h1:KjJaJ9iWZ3jOFZIf1Lqf4laDRCasjl0BCmnEGxkdLb4= +github.com/google/uuid v1.3.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/influxdata/influxdb-client-go/v2 v2.14.0 h1:AjbBfJuq+QoaXNcrova8smSjwJdUHnwvfjMF71M1iI4= +github.com/influxdata/influxdb-client-go/v2 v2.14.0/go.mod h1:Ahpm3QXKMJslpXl3IftVLVezreAUtBOTZssDrjZEFHI= +github.com/influxdata/line-protocol v0.0.0-20200327222509-2487e7298839 h1:W9WBk7wlPfJLvMCdtV4zPulc4uCPrlywQOmbFOhgQNU= +github.com/influxdata/line-protocol v0.0.0-20200327222509-2487e7298839/go.mod h1:xaLFMmpvUxqXtVkUJfg9QmT88cDaCJ3ZKgdZ78oO8Qo= +github.com/juju/gnuflag v0.0.0-20171113085948-2ce1bb71843d/go.mod h1:2PavIy+JPciBPrBUjwbNvtwB6RQlve+hkpll6QSNmOE= +github.com/oapi-codegen/runtime v1.0.0 h1:P4rqFX5fMFWqRzY9M/3YF9+aPSPPB06IzP2P7oOxrWo= +github.com/oapi-codegen/runtime v1.0.0/go.mod h1:LmCUMQuPB4M/nLXilQXhHw+BLZdDb18B34OO356yJ/A= +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/simonvetter/modbus v1.6.3 h1:kDzwVfIPczsM4Iz09il/Dij/bqlT4XiJVa0GYaOVA9w= +github.com/simonvetter/modbus v1.6.3/go.mod h1:hh90ZaTaPLcK2REj6/fpTbiV0J6S7GWmd8q+GVRObPw= +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.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +golang.org/x/net v0.23.0 h1:7EYJ93RZ9vYSZAIb2x3lnuvqO5zneoD6IvWjuhfxjTs= +golang.org/x/net v0.23.0/go.mod h1:JKghWKKOSdJwpW2GEx0Ja7fmaKnMsbu+MWVZTokSYmg= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/main.go b/main.go new file mode 100644 index 0000000..7a1ad82 --- /dev/null +++ b/main.go @@ -0,0 +1,165 @@ +package main + +import ( + "context" + "encoding/json" + "flag" + "log" + "os" + "strconv" + "strings" + "time" + + "github.com/simonvetter/modbus" + + influxdb2 "github.com/influxdata/influxdb-client-go/v2" +) + +type mod_bus struct { + Protocol string `json:"protocol"` + BaudRate int `json:"baud_rate"` + SlaveAddress int `json:"slave_address"` + Host string `json:"host"` + Port int `json:"port"` +} + +type register struct { + Name string `json:"name"` + Type string `json:"type"` + Address int `json:"address"` + Quantity int `json:"quantity"` + Factor float32 `json:"factor"` + Unit string `json:"unit"` +} + +type device struct { + Name string `json:"name"` + Type string `json:"type"` + Modbus mod_bus `json:"modbus"` + Registers []register `json:"registers"` +} +type config struct { + SampleRate int `json:"sample_rate"` + InfluxdbHost string `json:"influxdb_host"` + InfluxdbPort int `json:"influxdb_port"` + InfluxdbToken string `json:"influxdb_token"` + Devices []device `json:"devices"` +} + +var ( + logger log.Logger = *log.Default() + config_path string + config_cache config +) + +func init() { + logger.SetFlags(log.Llongfile | log.Ltime) +} + +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 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() + + type remote struct { + client *modbus.ModbusClient + registers []register + name string + } + var err error + var remotes []remote + var client *modbus.ModbusClient + + // setup influx connection + influxdb_url := "http://" + config_cache.InfluxdbHost + ":" + strconv.Itoa(config_cache.InfluxdbPort) + db_client := influxdb2.NewClient(influxdb_url, config_cache.InfluxdbToken) + defer db_client.Close() + ctx := context.Background() + write_api := db_client.WriteAPIBlocking("tkl", "home") + + for _, v := range config_cache.Devices { + if v.Modbus.Protocol == "tcp" { + url := v.Modbus.Protocol + "://" + v.Modbus.Host + ":" + strconv.Itoa(v.Modbus.Port) + client, err = modbus.NewClient(&modbus.ClientConfiguration{URL: url}) + if err != nil { + logger.Printf("failed to create modbus client: %v\n", err) + continue + } + client.SetUnitId(0x55) + err = client.Open() + if err != nil { + logger.Printf("failed to connect: %v\n", err) + continue + } + + var tmp remote + tmp.client = client + tmp.registers = v.Registers + tmp.name = v.Name + remotes = append(remotes, tmp) + } + } + + for { // ever + for _, remote := range remotes { + point := influxdb2.NewPointWithMeasurement(remote.name) + for _, reg := range remote.registers { + res, err := remote.client.ReadRegisters(uint16(reg.Address), uint16(reg.Quantity), modbus.HOLDING_REGISTER) + if err != nil { + logger.Printf("failed to read: %v\n", err) + error_strings := []string{ + "connection reset by peer", + "broken pipe", + } + for _, error_string := range error_strings { + if strings.Contains(err.Error(), error_string) { + logger.Print("trying to restart modbus client") + remote.client.Close() + err = remote.client.Open() + if err != nil { + logger.Printf("failed to restart: %v\n", err) + os.Exit(1) + } + } + } + continue + } + var tmp int32 + switch reg.Quantity { + case 1: + tmp = int32(res[0]) + case 2: + tmp = ((int32(res[0]) << 16) | int32(res[1])) + default: + logger.Printf("Unsupported quantity of registers (%d)", reg.Quantity) + continue + } + result := float32(tmp) * reg.Factor + point.AddField(reg.Name, result) + } + point.SetTime(time.Now()) + err := write_api.WritePoint(ctx, point) + if err != nil { + logger.Print(err) + } + err = write_api.Flush(ctx) + if err != nil { + logger.Print(err) + } + } + time.Sleep(time.Duration(config_cache.SampleRate * 1000000000)) + } +} diff --git a/pvcollect.service b/pvcollect.service new file mode 100644 index 0000000..f494581 --- /dev/null +++ b/pvcollect.service @@ -0,0 +1,12 @@ +[Unit] +Description=pv collect service +After=multi-user.target + +[Service] +Type=idle +ExecStart=/usr/bin/pvcollect -c /etc/pvcollect/config.json +Restart=on-failure +RestartSec=10s + +[Install] +WantedBy=multi-user.target