diff --git a/.gitignore b/.gitignore index 5fe77c1..4600318 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,3 @@ -huawei-modbus* +vonkje* config.yaml temp/* diff --git a/config.yaml.dist b/config.yaml.dist index 284654f..78e1d08 100644 --- a/config.yaml.dist +++ b/config.yaml.dist @@ -5,6 +5,7 @@ http: port: 8080 modbus: + run: true # Read metrics from inverters every interval read-metrics-interval: 15 # Seconds connections: @@ -25,3 +26,13 @@ modbus: unit-id: 2 power-meter: true luna2000: true + +power-prices: + sources: + all-in-power: + +victoria-metrics: + url: http://127.0.0.1:8428 + # Basic HTTP authentication + username: + password: diff --git a/docs/modbus.md b/docs/modbus.md new file mode 100644 index 0000000..7705b06 --- /dev/null +++ b/docs/modbus.md @@ -0,0 +1,5 @@ +# Modbus + +## Links +- **Modbus definitions** https://www.photovoltaikforum.com/core/attachment/251184-solar-inverter-modbus-interface-definitions-pdf/ +- **Helpful modbus definitions guide** https://community.openhab.org/t/reading-data-from-huawei-inverter-sun-2000-3ktl-10ktl-via-modbus-tcp-and-rtu/87670 diff --git a/docs/power-prices.md b/docs/power-prices.md new file mode 100644 index 0000000..a263ebe --- /dev/null +++ b/docs/power-prices.md @@ -0,0 +1,6 @@ +# Power prices +Power prices can be obtained from multiple sources. We want to create an enviroment which can accept multiple power price sources. + +## Support sources +- [All In Power](https://allinpower.nl) + diff --git a/go.sum b/go.sum index caed374..e76d3e3 100644 --- a/go.sum +++ b/go.sum @@ -4,14 +4,24 @@ github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= +github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA= github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM= 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/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY= github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ= github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4= github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0VQdvPDY= github.com/magiconair/properties v1.8.7/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= @@ -19,6 +29,8 @@ github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RR github.com/pelletier/go-toml/v2 v2.1.0 h1:FnwAJ4oYMvbT/34k9zzHuZNrhlz48GB3/s6at6/MHO4= github.com/pelletier/go-toml/v2 v2.1.0/go.mod h1:tJU2Z3ZkXwnxa4DPO899bsyIoywizdUvyaeZurnPPDc= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/prometheus/client_golang v1.19.0 h1:ygXvpU1AoN1MhdzckN+PyD9QJOSD4x7kmXYlnfbA6JU= github.com/prometheus/client_golang v1.19.0/go.mod h1:ZRM9uEAypZakd+q/x7+gmsvXdURP+DABIEIjnmDdp+k= github.com/prometheus/client_model v0.5.0 h1:VQw1hfvPvk3Uv6Qf29VrPF32JB6rtbgI6cYPYQjL0Qw= @@ -27,6 +39,8 @@ github.com/prometheus/common v0.48.0 h1:QO8U2CdOzSn1BBsmXJXduaaW+dY/5QLjfB8svtSz github.com/prometheus/common v0.48.0/go.mod h1:0/KsvlIEfPQCQ5I2iNSAWKPZziNCvRs5EC6ILDTlAPc= github.com/prometheus/procfs v0.12.0 h1:jluTpSng7V9hY0O2R9DzzJHYb2xULk9VTR1V1R/k6Bo= github.com/prometheus/procfs v0.12.0/go.mod h1:pcuDEFsWDnvcgNzo4EEweacyhjeA9Zk3cnaOZAZEfOo= +github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ= +github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog= github.com/sagikazarmark/locafero v0.4.0 h1:HApY1R9zGo4DBgr7dqsTH/JJxLTTsOt7u6keLGt6kNQ= github.com/sagikazarmark/locafero v0.4.0/go.mod h1:Pe1W6UlPYUk/+wc/6KFhbORCfqzgYEpgQ3O5fPuL3H4= github.com/sagikazarmark/slog-shim v0.1.0 h1:diDBnUNK9N/354PgrxMywXnAwEr1QZcOr6gto+ugjYE= @@ -52,6 +66,7 @@ github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UV github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= @@ -69,6 +84,8 @@ golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= google.golang.org/protobuf v1.32.0 h1:pPC6BG5ex8PDFnkbrGU3EixyhKcQ2aDuBS36lqK/C7I= google.golang.org/protobuf v1.32.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA= gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/grafana/sun2000.json b/grafana/sun2000.json index 389cb65..3a507e4 100644 --- a/grafana/sun2000.json +++ b/grafana/sun2000.json @@ -831,7 +831,7 @@ "fullMetaSearch": false, "includeNullMetadata": true, "instant": false, - "legendFormat": "Efficiency", + "legendFormat": "{{inverter}} Efficiency", "range": true, "refId": "A", "useBackend": false diff --git a/main.go b/main.go index 3699dc2..955ba40 100644 --- a/main.go +++ b/main.go @@ -9,15 +9,19 @@ import ( "gijs.eu/vonkje/http" "gijs.eu/vonkje/modbus" + "gijs.eu/vonkje/power_prices" + "gijs.eu/vonkje/packages/victoria_metrics" "github.com/spf13/viper" "github.com/sirupsen/logrus" ) type Config struct { - LogLevel string `mapstructure:"log-level"` - HTTP http.Config `mapstructure:"http"` - Modbus modbus.Config `mapstructure:"modbus"` + LogLevel string `mapstructure:"log-level"` + HTTP http.Config `mapstructure:"http"` + Modbus modbus.Config `mapstructure:"modbus"` + VictoriaMetrics victoria_metrics.Config `mapstructure:"victoria-metrics"` + PowerPrices power_prices.Config `mapstructure:"power-prices"` } var ( @@ -85,12 +89,16 @@ func main() { if err != nil { logger.WithError(err).Panic("Failed to create modbus client") } - go modbusClient.Start() httpServer := http.New(config.HTTP, errChannel, stopCtx, logger) go httpServer.Start() + victoriaMetricsClient := victoria_metrics.New(config.VictoriaMetrics) + + powerPricesClient := power_prices.New(config.PowerPrices, errChannel, stopCtx, logger, victoriaMetricsClient) + go powerPricesClient.Start() + <-stopCtx.Done() modbusClient.Close() diff --git a/modbus/modbus.go b/modbus/modbus.go index eedc6bf..2212412 100644 --- a/modbus/modbus.go +++ b/modbus/modbus.go @@ -37,6 +37,7 @@ type Connection struct { } type Config struct { + Run bool `mapstructure:"run"` ReadMetricsInterval uint `mapstructure:"read-metrics-interval"` Connections []ConnectionConfig `mapstructure:"connections"` } @@ -99,6 +100,11 @@ func (m *Modbus) Close() { } func (m *Modbus) Start() { + if !m.config.Run { + m.logger.Warn("Modbus metrics collector is disabled") + return + } + m.logger.Info("Starting modbus metrics collector") m.updateMetrics() diff --git a/packages/victoria_metrics/entities.go b/packages/victoria_metrics/entities.go new file mode 100644 index 0000000..79df19e --- /dev/null +++ b/packages/victoria_metrics/entities.go @@ -0,0 +1,24 @@ +package victoria_metrics + +type Config struct { + URL string + Username string + Password string +} + +type VictoriaMetricsRequest struct { + Metric map[string]string `json:"metric"` + Values []float64 `json:"values"` + Timestamps []int64 `json:"timestamps"` +} + +type VictoriaMetricsQueryResponse struct { + Status string `json:"status"` + Data struct { + ResultType string `json:"resultType"` + Result []struct { + Metric map[string]string `json:"metric"` + Values [][]interface{} `json:"values"` + } `json:"result"` + } `json:"data"` +} diff --git a/packages/victoria_metrics/victoria_metrics.go b/packages/victoria_metrics/victoria_metrics.go new file mode 100644 index 0000000..099f6dd --- /dev/null +++ b/packages/victoria_metrics/victoria_metrics.go @@ -0,0 +1,128 @@ +package victoria_metrics + +import ( + "fmt" + "time" + "bytes" + "errors" + "net/url" + "strconv" + "net/http" + "io/ioutil" + "encoding/json" + "encoding/base64" +) + +type VictoriaMetrics struct { + Config Config + Client *http.Client +} + +// New creates a new GoVictoria instance +func New(config Config) *VictoriaMetrics { + return &VictoriaMetrics{ + Config: config, + Client: &http.Client{}, + } +} + +// SendMetrics sends the metrics to VictoriaMetrics +func (g *VictoriaMetrics) SendMetrics(requests []VictoriaMetricsRequest) error { + if len(requests) == 0 { + return errors.New("No requests to send") + } + + // Loop through the request and build the body + body := "" + for _, requestBody := range requests { + jsonRequest, err := json.Marshal(requestBody) + if err != nil { + return err + } + + body += string(jsonRequest) + } + + // Create the request to Victoria Metrics + request, err := http.NewRequest("POST", g.Config.URL+"/api/v1/import", bytes.NewBuffer([]byte(body))) + request.Header.Add("Authorization", "Basic "+BasicAuth(g.Config.Username, g.Config.Password)) + request.Header.Add("User-Agent", "Vonkje (github.com/GJSBRT/vonkje)") + + // Send the request to Victoria Metrics + response, err := g.Client.Do(request) + if err != nil { + return err + } + + // Close the response body + defer response.Body.Close() + + // Check if the status code is not 204 + if response.StatusCode != http.StatusNoContent { + return errors.New(fmt.Sprintf("Victoria Metrics returned a non-200 status code: %d", response.StatusCode)) + } + + return nil +} + +// QueryTimeRange queries Victoria Metrics for metrics in a time range +func (g *VictoriaMetrics) QueryTimeRange(promql string, startTime time.Time, endTime time.Time, step string) (VictoriaMetricsQueryResponse, error) { + // Check if the start time is before the end time + if startTime.After(endTime) { + return VictoriaMetricsQueryResponse{}, errors.New("Start time must be before end time") + } + + // Add the query parameters to the request + params := url.Values{} + params.Add("query", promql) + params.Add("start", strconv.FormatInt(startTime.Unix(), 10)) + params.Add("end", strconv.FormatInt(endTime.Unix(), 10)) + params.Add("step", step) + + url := g.Config.URL + "/api/v1/query_range?" + params.Encode() + + // Create the request to Victoria Metrics + request, err := http.NewRequest("GET", url, nil) + if err != nil { + return VictoriaMetricsQueryResponse{}, err + } + + // Add the query parameters to the request + request.Header.Add("Authorization", "Basic "+BasicAuth(g.Config.Username, g.Config.Password)) + request.Header.Add("User-Agent", "Vonkje (github.com/GJSBRT/vonkje)") + + // Send the request to Victoria Metrics + response, err := g.Client.Do(request) + if err != nil { + return VictoriaMetricsQueryResponse{}, err + } + + // Close the response body + defer response.Body.Close() + + // Check if the status code is not 200 + if response.StatusCode != http.StatusOK { + return VictoriaMetricsQueryResponse{}, errors.New(fmt.Sprintf("Victoria Metrics returned a non-200 status code: %d", response.StatusCode)) + } + + // Read the response body + body, err := ioutil.ReadAll(response.Body) + if err != nil { + return VictoriaMetricsQueryResponse{}, err + } + + // Unmarshal the response + var metrics VictoriaMetricsQueryResponse + err = json.Unmarshal([]byte(body), &metrics) + if err != nil { + return VictoriaMetricsQueryResponse{}, err + } + + return metrics, nil +} + +// BasicAuth returns the base64 encoded string for basic auth +func BasicAuth(username, password string) string { + auth := username + ":" + password + return base64.StdEncoding.EncodeToString([]byte(auth)) +} diff --git a/power_prices/power_prices.go b/power_prices/power_prices.go new file mode 100644 index 0000000..f19863c --- /dev/null +++ b/power_prices/power_prices.go @@ -0,0 +1,114 @@ +package power_prices + +import ( + "fmt" + "time" + "context" + + "gijs.eu/vonkje/packages/victoria_metrics" + + "github.com/sirupsen/logrus" +) + +type PowerPriceSourceConfigs struct { + AllInPowerConfig AllInPowerConfig `json:"all-in-power"` +} + +type Config struct { + Sources PowerPriceSourceConfigs `json:"sources"` +} + +type PowerPrices struct { + Config Config + errChannel chan error + ctx context.Context + logger *logrus.Logger + VictoriaMetrics *victoria_metrics.VictoriaMetrics +} + +type PowerPriceSource interface { + GetName() string + GetPricesKwH(time.Time) (map[time.Time]float64, error) +} + +var sources = []PowerPriceSource{} + +var ( + ErrFailedToAuthenticate = fmt.Errorf("Failed to authenticate") + ErrFailedToRetrieveData = fmt.Errorf("Failed to retrieve data") +) + +func New( + config Config, + errChannel chan error, + ctx context.Context, + logger *logrus.Logger, + victoriaMetrics *victoria_metrics.VictoriaMetrics, +) *PowerPrices { + sources = append(sources, newAllInPower(config.Sources.AllInPowerConfig)) + + return &PowerPrices{ + Config: config, + errChannel: errChannel, + ctx: ctx, + logger: logger, + VictoriaMetrics: victoriaMetrics, + } +} + +func (pp *PowerPrices) addPricesOfSource(source PowerPriceSource) error { + prices, err := source.GetPricesKwH(time.Now()) + if err != nil { + return err + } + + metrics := []victoria_metrics.VictoriaMetricsRequest{} + for timestamp, price := range prices { + metrics = append(metrics, victoria_metrics.VictoriaMetricsRequest{ + Metric: map[string]string{ + "__name__": "power_price", + "source": source.GetName(), + }, + Values: []float64{price}, + Timestamps: []int64{int64(timestamp.Unix() * 1000)}, + }) + } + + err = pp.VictoriaMetrics.SendMetrics(metrics) + if err != nil { + return err + } + + return nil +} + +func (pp *PowerPrices) updateMetrics() error { + pp.logger.Info("Updating power prices") + + for _, source := range sources { + err := pp.addPricesOfSource(source) + if err != nil { + return err + } + } + + return nil +} + +func (pp *PowerPrices) Start() { + pp.logger.Info("Starting power price collector") + + now := time.Now() + + nextMidnight := time.Date(now.Year(), now.Month(), now.Day()+1, 1, 0, 0, 0, time.Local) + durationUntilMidnight := nextMidnight.Sub(now) + + time.Sleep(durationUntilMidnight) + + err := pp.updateMetrics() + if err != nil { + pp.logger.WithError(err).Error("Failed to update metrics") + } + + go pp.Start() +} diff --git a/power_prices/source_allinpower.go b/power_prices/source_allinpower.go new file mode 100644 index 0000000..081f433 --- /dev/null +++ b/power_prices/source_allinpower.go @@ -0,0 +1,83 @@ +package power_prices + +import ( + "time" + "net/http" + "io/ioutil" + "encoding/json" +) + +type AllInPowerConfig struct {} + +type AllInPower struct { + name string + Config AllInPowerConfig + Resolution uint + Client *http.Client +} + +func newAllInPower(config AllInPowerConfig) *AllInPower { + return &AllInPower{ + name: "all-in-power", + Config: config, + Resolution: 60, + Client: &http.Client{}, + } +} + +func (a *AllInPower) GetName() string { + return a.name +} + +type spotMarketPriceResponse struct { + Id int `json:"id"` + Timestamps []string `json:"timestamps"` + Created string `json:"created"` + Updated string `json:"updated"` + ProductType string `json:"product_type"` + Date string `json:"date"` + Unit string `json:"unit"` + Prices []float64 `json:"prices"` +} + +func (a *AllInPower) GetPricesKwH(timestamp time.Time) (map[time.Time]float64, error) { + request, err := http.NewRequest("GET", "https://api.allinpower.nl/troodon/api/p/spot_market/prices/?date="+timestamp.Format(time.DateOnly)+"&product_type=ELK", nil) + if err != nil { + return nil, err + } + request.Header.Del("User-Agent") + + response, err := a.Client.Do(request) + if err != nil { + return nil, err + } + defer response.Body.Close() + + if response.StatusCode != http.StatusOK { + return nil, ErrFailedToRetrieveData + } + + body, err := ioutil.ReadAll(response.Body) + if err != nil { + return nil, err + } + + var responseBody spotMarketPriceResponse + err = json.Unmarshal(body, &responseBody) + if err != nil { + return nil, err + } + + prices := make(map[time.Time]float64) + + for i, price := range responseBody.Prices { + timestamp, err := time.Parse("2006-01-02T15:04:05.000000Z", responseBody.Timestamps[i]) + if err != nil { + return nil, err + } + + prices[timestamp] = price + } + + return prices, nil +} diff --git a/readme.md b/readme.md index 65e57f3..2618d46 100644 --- a/readme.md +++ b/readme.md @@ -1,5 +1,10 @@ # Vonkje +Vonkje is a small services which manages communication, metrics, decision making and control of a solar plant. The goal of this application is to provide insight and to improve the efficiency of a solar plant. -## Linkjes -- **Modbus definities** https://www.photovoltaikforum.com/core/attachment/251184-solar-inverter-modbus-interface-definitions-pdf/ -- **De echte nuttige modbus definities** https://community.openhab.org/t/reading-data-from-huawei-inverter-sun-2000-3ktl-10ktl-via-modbus-tcp-and-rtu/87670 +## Runtime Dependencies +- Grafana for visualisation +- Victoria Metrics for metrics storage + +## Development Dependencies +- **Golang** 1.22 or higher +- **Docker** for Victoria Metrics and Grafana