Skip to content

Go library

go-sunspec reads and serves SunSpec devices over Modbus. As a client it finds the SunS marker, walks the model chain, and decodes every point of the 112 compiled SunSpec models into typed values with scale factors and units applied. As a server it lays out a model chain from point values given by name and serves it through go-modbus, as a simulator or as the SunSpec interface of your own equipment. It is pure Go with no cgo, and built on go-modbus, which supplies the transport. The command-line tool built on it, sunspecctl, has its own repository since go-sunspec v0.5.0; go-sunspec is the library only.

Terminal window
go get github.com/otfabric/go-sunspec

go-sunspec requires Go 1.23 or later and go-modbus v1.5.0 or later, and depends on nothing else. The packages are sunspec (discovery, reading, decoding, encoding and serving), sunspec/registry (model metadata) and sunspec/devicefile (SunSpec devices as JSON files). This page follows go-sunspec v0.6.0.

You create and own the go-modbus client. go-sunspec only borrows it: Device holds a reference and never closes the connection.

main.go
package main
import (
"context"
"fmt"
"log"
"github.com/otfabric/go-modbus"
"github.com/otfabric/go-sunspec"
)
func main() {
client, err := modbus.New(modbus.Config{
URL: "tcp://192.168.1.100:502",
})
if err != nil {
log.Fatal(err)
}
if err := client.Open(); err != nil {
log.Fatal(err)
}
defer client.Close()
ctx := context.Background()
device, err := sunspec.Discover(ctx, client, &sunspec.DiscoverOptions{UnitID: 1})
if err != nil {
log.Fatal(err)
}
results, err := device.ReadAll(ctx)
if err != nil {
log.Fatal(err)
}
for _, dm := range results {
fmt.Printf("Model %d (%s)\n", dm.ModelID, dm.Name)
// Walk visits the model's top-level group and every nested group instance.
dm.Group.Walk(func(g *sunspec.DecodedGroup) {
for _, p := range g.Points {
if !p.Implemented {
continue
}
if p.ScaledValue != nil {
fmt.Printf(" %s = %g %s\n", p.Name, *p.ScaledValue, p.Units)
} else {
fmt.Printf(" %s = %v\n", p.Name, p.RawValue)
}
}
})
}
}

Detect, Discover and every Device method only read registers, with function codes 03 and 04. A monitoring program can make that a property of its connection, so that no code path, its own or a dependency’s, can write to the device:

client, err := modbus.New(modbus.Config{
URL: "tcp://192.168.1.100:502",
AllowedFunctionCodes: sunspec.ReadFunctionCodes(), // FC 03 and FC 04
})

The client refuses any other function code with modbus.ErrFunctionCodeNotAllowed before a byte reaches the wire. Everything in go-sunspec works through such a client unchanged; sunspecctl and SunSpec Explorer read devices this way.

Every go-modbus transport works unchanged. Only the client configuration differs.

client, err := modbus.New(modbus.Config{URL: "tcp://192.168.1.100:502"})

Pass the device’s Modbus address as DiscoverOptions.UnitID. On a serial bus that is the device address set on the inverter or meter.

// Is this a SunSpec device, and where?
result, err := sunspec.Detect(ctx, client, opts)
// Enumerate all models and return a Device ready for reading.
device, err := sunspec.Discover(ctx, client, &sunspec.DiscoverOptions{
UnitID: 1,
BaseAddresses: []uint16{40000, 50000, 0},
})

DiscoverOptions has UnitID, RegType, BaseAddresses, MaxModels and MaxAddressSpan. Nil options use the go-modbus SunSpec defaults: unit 1, holding registers, and the base addresses 0, 40000, 50000, 1, 39999, 40001, 49999, 50001. Set RegType and BaseAddresses if the device keeps its map in input registers or at another address. Detect returns every probe attempt with its error, which shows what the device actually answered.

sunspec.Open is an alias of Discover. Despite the name, it does not open the Modbus connection; call client.Open() first.

The chain walk stops after MaxModels headers (256 by default) without an error. When it has not reached the end marker by then, Device.Discovery.Warnings says “model chain not terminated”: the device has more models, or what was walked is not a model chain.

// All models at once.
decoded, err := device.ReadAll(ctx)
// One model by ID (103 = three-phase inverter).
inverter, err := device.ReadModelByID(ctx, 103)
// One point.
inst := device.ModelByID(101)
point, err := device.ReadPoint(ctx, *inst, "W")
fmt.Printf("Power: %g W\n", *point.ScaledValue)

Every read of a model includes its two header registers (ID and length) and compares them with the discovery result. A device whose model chain changed since Discover (a firmware update, a reconfiguration, another device behind the same address) fails the read with ErrModelChanged instead of having its registers decoded with a stale layout; call Discover again. A model that needs more than one request is read from its header address in chunks of 125 registers, so a model of 124 or 125 data registers takes two requests.

Each decoded point is a DecodedPoint:

Field Meaning
Name, Type, Units From the model definition
Implemented false when the device sent the type’s “not implemented” sentinel
RawValue The value as read: integer, float, string, IP or MAC address
ScaledValue RawValue × 10^scale factor as *float64; nil for non-numeric points or when no scale factor applies
SFName, SFRawValue The scale factor point (or literal) and its value
Symbols Names of the active enum value or bitfield bits
RegisterOffset, RegisterCount Position in the model, counted from its ID register

Always check Implemented before using a value, and check ScaledValue for nil before dereferencing it. DecodedPoint implements json.Marshaler; NaN and infinite floats are encoded as null.

A decoded model has the same tree shape as its definition. DecodedModel.Group is the top-level group; Group.Groups holds nested instances in register order.

dm, _ := device.ReadModelByID(ctx, 705) // DER volt-var
for _, crv := range dm.Group.GroupsNamed("Crv") { // crv.Index is 1, 2, ...
for _, pt := range crv.GroupsNamed("Pt") {
fmt.Println(crv.Index, pt.Index, *pt.Point("V").ScaledValue, *pt.Point("Var").ScaledValue)
}
}
w := dm.Point("W") // first point with that name anywhere in the model, or nil

Point looks at a group’s own points, FindPoint also searches nested groups depth first, GroupsNamed returns the instances of one nested group, and Walk visits the whole tree. Scale factors are resolved through the tree, so a curve point scaled by V_SF finds it in the top-level group. Anything that could not be decoded is listed in DecodedModel.Warnings.

DecodeModel decodes a register slice with no I/O, which is useful for captures, simulators and tests:

func DecodeModel(regs []uint16, meta *registry.ModelMeta, instanceAddr uint16) (*DecodedModel, error)
import "github.com/otfabric/go-sunspec/registry"
registry.Known(103) // true
registry.ByID(103).Label // "Inverter (Three Phase)"
registry.Count() // 112
all := registry.All() // map[uint16]*ModelMeta (shallow copy)

The registry is generated from the official sunspec/models JSON. A weekly workflow in the repository checks upstream for new and changed models.

A Server is a SunSpec device held in memory: the SunS marker, each model with its ID and length registers and its data, and the end marker. You give the models as point values by SunSpec name, and the library encodes them with the registry’s schemas.

serve.go
srv, err := sunspec.NewServer(&sunspec.ServerOptions{BaseAddress: 40000, UnitID: 1},
sunspec.ServerModel{ID: 1, Values: (&sunspec.Values{}).
Set("Mn", "Acme").Set("Md", "PV-5000").Set("SN", "A-0001")},
sunspec.ServerModel{ID: 103, Values: (&sunspec.Values{}).
Set("W", 4200).Set("W_SF", 0). // 4200 W
Set("PhVphA", 230.4).Set("V_SF", -1). // stored as 2304
Set("St", "MPPT")}, // enum by symbol name
sunspec.ServerModel{ID: 64900, Registers: []uint16{1, 2, 3}}, // vendor model, raw
)
if err != nil {
return err
}
// A Server is a go-modbus RequestHandler: the Modbus server stays yours.
ms, err := modbus.NewServer(&modbus.ServerConfig{URL: "tcp://0.0.0.0:1502"}, srv)
if err != nil {
return err
}
if err := ms.Start(); err != nil {
return err
}
defer ms.Stop()
// Change values while the device is being read.
err = srv.SetPoint(1, "W", 4350) // the model at index 1 of the chain

srv.Serve(ctx, &modbus.ServerConfig{...}) does the NewServer, Start, wait and shutdown sequence in one call, for when you do not need the Modbus server yourself. The Server owns no listener: go-modbus does, with its idle timeout, client limit, TLS (tcp+tls://), logging and metrics.

ServerOptions field Meaning
UnitID The unit ID the device answers; 0 selects 1
BaseAddress Address of the SunS marker. The zero value is address 0
RegType modbus.HoldingRegister (default) or modbus.InputRegister
OnWrite Called with each client write, decoded, before it takes effect; returning an error refuses it
SilentToOtherUnits Leave requests for other unit IDs unanswered instead of answering exception 0x0B, as a device on a serial line would (since v0.5.1)

Values holds a group instance’s Points (by name) and nested Groups, with the chainable Set, Add (adds an instance of a repeating group and returns it) and Clone.

  • Engineering values. A point with a scale factor takes the real value. The exponent is the value you give the sunssf point of that name, looked up the way the decoder does it; the stored integer is value / 10^sf, rounded to the nearest integer with halves away from zero. Without the sunssf point the value is stored unscaled.
  • Never wrapped or clamped. A value outside the point’s range after scaling fails with ErrValueOutOfRange. So does giving a type’s “not implemented” sentinel explicitly (65535 for a uint16, NaN for a float), so that a measurement cannot turn into “not implemented” by accident.
  • Not implemented. A point you do not set, or set to nil, is served as its type’s “not implemented” value.
  • Types. Numbers for integer, accumulator, float, count and sunssf points; a symbol name or a number for enums; a []string of bit names or a number for bitfields; strings for string, ipaddr, ipv6addr and eui48; or, for any point, a []uint16 with exactly its registers.
  • Groups. Count points such as NPt or NCrv are filled in from the number of instances you add. Groups with fewer instances than an explicit count are completed with not-implemented ones.

SetPoint sets one top-level point, SetValues replaces all values of a model, Update hands a copy of the current values to a function and encodes what it leaves, and SetRegisters replaces a model’s data registers. Each is atomic for a reader: a model read in one Modbus request never shows half an update. An update cannot change a model’s length (ErrModelLayout), and nothing changes when an update fails. Server.Model(index) returns a decoded snapshot, Server.Models() the layout as discovery would find it. index is the position in the chain, starting at 0, because a device can hold several models with the same ID.

What a client sees:

Request Answer
Read of the configured register type inside the device map (it may span models) The registers
Read or write touching an address outside the map, or the other register type Exception 2
Write (FC 6, FC 16) covering whole points with SunSpec access RW, in a model served from Values Accepted, after OnWrite
Write to a read-only point, the marker, a header, a count point, part of a point, a model served from Registers Exception 2; nothing changes
Request for another unit ID Exception 0x0B; no answer with SilentToOtherUnits
Request for unit ID 0 (broadcast) No answer, and not carried out (since v0.5.1)
Coils, discrete inputs, FC 22, FC 23 Exception 1

With RegType set to input registers nothing is writable. The Server stores an accepted write as it is and does not judge the value: that is what OnWrite is for. OnWrite and the function given to Update run with the Server locked, so they must not call its methods.

Package devicefile reads and writes a SunSpec device as a JSON file: capture a real device, edit it, serve it again. It is the format sunspecctl serve runs and sunspecctl capture writes, and SunSpec Explorer exports (since v0.6.0, when it moved from sunspecctl into the library).

import "github.com/otfabric/go-sunspec/devicefile"
// Serve a file: every value and the layout are validated first.
file, err := devicefile.Load("device.json")
if err != nil {
return err // e.g. "device.json: line 3, column 14: invalid character ..."
}
srv, err := file.Server(nil)
if err != nil {
return err // e.g. "models[1] (model 103): point W: ..."
}
err = srv.Serve(ctx, &modbus.ServerConfig{URL: "tcp://127.0.0.1:1502"})
// Copy a real device into a file that serves the same registers and values.
captured, err := devicefile.Capture(ctx, device, false)
if err != nil {
return err
}
err = captured.WriteFile("device.json")
  • Capture writes models that decode cleanly as point values, and a model without a schema, or one that would not read back the same, as registers. Capture(ctx, device, true) writes every model as registers.
  • Template: devicefile.Template(registry.ByID(103)) is a model with every point present and null, to fill in.
  • Strict: unknown members, a point given twice and names the model does not have are errors (devicefile.ErrInvalid, with the model, group instance and point), and malformed JSON reports its line and column (devicefile.ErrSyntax).
  • JSON Schema: devicefile/devicefile.schema.json (draft 2020-12), also returned by devicefile.Schema(), for editors and other tools.
values := dm.Values() // a decoded model back into Values
regs, err := sunspec.EncodeModel(registry.ByID(103), values) // ID and length registers, then the data

EncodeModel is the inverse of DecodeModel. DecodedModel.Values() returns what EncodeModel needs to rebuild a model that was read: for a model that decoded without warnings, decoding the encoded values gives the same points. That is how a real device can be captured and served again, which is what sunspecctl capture and serve do.

Use errors.Is and errors.As:

Situation Reported as
No SunS marker at any probed base ErrNotSunSpec
Model ID not in the discovery result ErrUnknownModel
Point name not in the decoded model ErrPointNotFound
Register slice too short, or decode failure *DecodeError wrapping ErrDecode
Multi-chunk read failed part way ErrPartialRead, wrapping the Modbus cause
The device no longer has the discovered model at that address ErrModelChanged
Model chain malformed, a model that runs past address 65535 or leaves no room for the next header, or a chain over its limit ErrModelChainInvalid, ErrModelChainLimitExceeded (re-exported from go-modbus)
Chain longer than MaxModels not an error: a warning in Device.Discovery.Warnings
Nil client, or a register range past 65535 error wrapping modbus.ErrUnexpectedParameters; nothing is sent
Unsupported point type not an error: a warning on DecodedModel, raw registers kept
A value that cannot be encoded: unknown point, wrong Go type, unknown symbol *EncodeError matching ErrEncode (and ErrPointNotFound for an unknown point)
A value that does not fit its point after scaling *EncodeError matching ErrEncode and ErrValueOutOfRange
Models that do not fit the address space, or an update that would change a model’s length ErrModelLayout
Server model index out of range ErrUnknownModel
Transport, timeout, Modbus exception the go-modbus error, often wrapped
device, err := sunspec.Discover(ctx, client, opts)
if errors.Is(err, sunspec.ErrNotSunSpec) {
// not a SunSpec device at the probed addresses
}
dm, err := device.ReadModelByID(ctx, 101)
var dec *sunspec.DecodeError
if errors.As(err, &dec) {
log.Printf("model %d point %s at offset %d: %s", dec.ModelID, dec.PointName, dec.Offset, dec.Message)
}

ErrUnknownModel does not mean “no local schema”. A model without a compiled definition is still discovered, with ModelInstance.SchemaKnown set to false, and ReadModel returns it with raw registers and a warning.

Reads can be partial. ReadAll returns the models decoded so far together with the first error, and ReadModel can return a partial DecodedModel with a non-nil error after a transport failure. A model that fails with ErrModelChanged comes back with the data registers that were read and no decoded group. Inspect both return values.

A context deadline overrides the client’s Timeout in go-modbus. ReadAll makes several requests under one context, so when one response is lost, that request uses up the rest of the context’s time and the models after it fail with the context’s error. Give long multi-model reads a deadline that suits a single request, or none and rely on Timeout.

Timeouts, retries, concurrency and observability

Section titled “Timeouts, retries, concurrency and observability”

go-sunspec defines no logger, metrics, retry or timeout settings of its own. Configure them on the go-modbus client (Timeout, RetryPolicy, Logger, Metrics, MaxConns) and they apply to every SunSpec read, or on the go-modbus server for a served device. Device methods are safe for concurrent use to the same extent as the underlying *modbus.Client. Server methods are safe for concurrent use with each other and with the requests being served. The registry is safe for concurrent reads; do not call registry.Register, which is for generated code only.

  • The client side only reads. Device does not write SunSpec control points. You can write registers with the same go-modbus client (client.WriteRegisters) at the address and offset of the discovered model, and EncodeModel gives you the registers for values by point name, but go-sunspec does not validate what the value does to the device.
  • Only models in the compiled registry are decoded and encoded by name. Others are read and served as raw registers.
  • A served device’s layout is fixed when the Server is created. A model longer than 125 registers takes several requests to read and can show a different update in each, as with any Modbus device.
  • Treat decoded values as untrusted input. Validate ranges before using them for control decisions, and escape strings such as the manufacturer or serial number before putting them in HTML, SQL or shell commands.

go-modbus has its own sunspec subpackage with sunspec.Detect, sunspec.ReadModelHeaders and sunspec.Discover. It reads through a sunspec.Reader interface and returns only the marker location and the model headers (ID, length, start and end address), not decoded points. Use it when you only need an inventory or fingerprint and want no extra dependency. *modbus.Client satisfies sunspec.Reader through a small adapter:

// reader adapts *modbus.Client to the go-modbus sunspec.Reader interface.
type reader struct{ client *modbus.Client }
func (r reader) ReadRawBytes(ctx context.Context, unitID uint8, addr, byteCount uint16, regType sunspec.RegType) ([]byte, error) {
return r.client.ReadRegisterBytes(ctx, unitID, addr, byteCount, regType)
}
result, err := sunspec.Discover(ctx, reader{client}, &sunspec.Options{UnitID: 1, RegType: sunspec.HoldingRegister})

Here sunspec is github.com/otfabric/go-modbus/sunspec, not the go-sunspec package. See the Modbus overview for the rest of go-modbus.