From 7549beaf44f0d326d56e7f5c357cd3c5aaf84a13 Mon Sep 17 00:00:00 2001 From: andig Date: Thu, 27 Aug 2026 15:29:50 +0200 Subject: [PATCH] Services: add sunspec/read for reading a point value from a device (#33230) --- util/service/sunspec.go | 119 +++++++++++++++++++++++++++++++++++ util/service/sunspec_test.go | 29 +++++++++ 2 files changed, 148 insertions(+) create mode 100644 util/service/sunspec.go create mode 100644 util/service/sunspec_test.go diff --git a/util/service/sunspec.go b/util/service/sunspec.go new file mode 100644 index 000000000..c7c481c69 --- /dev/null +++ b/util/service/sunspec.go @@ -0,0 +1,119 @@ +package service + +import ( + "context" + "fmt" + "net/http" + "time" + + "github.com/evcc-io/evcc/plugin" + "github.com/evcc-io/evcc/server/service" + "github.com/evcc-io/evcc/util" + "github.com/evcc-io/evcc/util/modbus" + "github.com/fatih/structs" + "github.com/spf13/cast" +) + +// SunSpecQuery combines modbus settings, sunspec point and additional parameters +type SunSpecQuery struct { + modbus.Settings `mapstructure:",squash"` + Value string // sunspec point, e.g. 124:0:InOutWRte_RvrtTms + Scale float64 // scaling factor + ResultType string // type cast (int, float, string) +} + +func init() { + mux := http.NewServeMux() + mux.HandleFunc("GET /read", sunspecRead) + + service.Register("sunspec", mux) +} + +// sunspecRead reads a sunspec point value from a device based on URL parameters +// Returns single value as array (for UI compatibility) +func sunspecRead(w http.ResponseWriter, req *http.Request) { + // Convert URL query parameters to map for decoding + cc := make(map[string]any) + for k := range req.URL.Query() { + cc[k] = req.URL.Query().Get(k) + } + + query := SunSpecQuery{ + Scale: 1.0, + } + + if err := util.DecodeOther(cc, &query); err != nil { + jsonError(w, http.StatusBadRequest, err) + return + } + + // Validate required parameters + if (query.URI == "" && query.Device == "") || query.Value == "" { + jsonError(w, http.StatusBadRequest, fmt.Errorf("uri or device and value parameters are required")) + return + } + + // Cache per exact request: distinct id/point/scale/resulttype must not share a converted value + cacheKey := "sunspec/" + req.URL.Query().Encode() + + mu.RLock() + if entry, ok := cache[cacheKey]; ok && time.Since(entry.timestamp) < cacheTTL { + mu.RUnlock() + jsonWrite(w, []string{cast.ToString(entry.value)}) + return + } + mu.RUnlock() + + // Use background context so connection isn't tied to HTTP request lifecycle + value, err := readPointValue(context.TODO(), query) + if err != nil { + jsonError(w, http.StatusInternalServerError, err) + return + } + + res := toString(value, query.ResultType) + + mu.Lock() + cache[cacheKey] = cacheEntry{ + value: res, + timestamp: time.Now(), + } + mu.Unlock() + + jsonWrite(w, []string{res}) +} + +// readPointValue reads a sunspec point value by reusing the sunspec plugin +func readPointValue(ctx context.Context, query SunSpecQuery) (any, error) { + // Convert Settings to map (plugin expects Settings fields at top level) + cfg := structs.Map(query.Settings) + + cfg["value"] = []string{query.Value} + cfg["scale"] = query.Scale + + p, err := plugin.NewModbusSunspecFromConfig(ctx, cfg) + if err != nil { + return 0, fmt.Errorf("failed to create sunspec plugin: %w", err) + } + + g, err := p.(plugin.FloatGetter).FloatGetter() + if err != nil { + return nil, err + } + + var res float64 + func() { + defer func() { + if r := recover(); r != nil { + err = fmt.Errorf("read failed: %v", r) + } + }() + + res, err = g() + }() + if err != nil { + return nil, err + } + + return res, nil +} diff --git a/util/service/sunspec_test.go b/util/service/sunspec_test.go new file mode 100644 index 000000000..545f7cdf0 --- /dev/null +++ b/util/service/sunspec_test.go @@ -0,0 +1,29 @@ +package service + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestSunspecRead_MissingValue(t *testing.T) { + // point value is required + req := httptest.NewRequest("GET", "/read?uri=192.168.1.1:502&id=1", nil) + w := httptest.NewRecorder() + + sunspecRead(w, req) + + assert.Equal(t, http.StatusBadRequest, w.Code) +} + +func TestSunspecRead_MissingConnection(t *testing.T) { + // uri or device is required + req := httptest.NewRequest("GET", "/read?id=1&value=124:0:InOutWRte_RvrtTms", nil) + w := httptest.NewRecorder() + + sunspecRead(w, req) + + assert.Equal(t, http.StatusBadRequest, w.Code) +}