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.
Install
Section titled “Install”go get github.com/otfabric/go-sunspecgo-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.
Connect and read
Section titled “Connect and read”You create and own the go-modbus client. go-sunspec only borrows it: Device holds a reference and never closes the connection.
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) } } }) }}A read-only client
Section titled “A read-only client”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.
Transports
Section titled “Transports”Every go-modbus transport works unchanged. Only the client configuration differs.
client, err := modbus.New(modbus.Config{URL: "tcp://192.168.1.100:502"})client, err := modbus.New(modbus.Config{ URL: "rtu:///dev/ttyUSB0", Speed: 9600, DataBits: 8, Parity: modbus.ParityNone, StopBits: 1,})Many SunSpec devices on RS-485 use 9600 8N1, which is not the go-modbus default (19200, no parity, 2 stop bits).
client, err := modbus.New(modbus.Config{URL: "rtuovertcp://192.168.1.50:4001"})Pass the device’s Modbus address as DiscoverOptions.UnitID. On a serial bus that is the device address set on the inverter or meter.
Discovery
Section titled “Discovery”// 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.
Reading models and points
Section titled “Reading models and points”// 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.
Groups
Section titled “Groups”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-varfor _, 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 nilPoint 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.
Offline decoding
Section titled “Offline decoding”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)Registry
Section titled “Registry”import "github.com/otfabric/go-sunspec/registry"
registry.Known(103) // trueregistry.ByID(103).Label // "Inverter (Three Phase)"registry.Count() // 112all := 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.
Serving a device
Section titled “Serving a device”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.
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 chainsrv.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
Section titled “Values”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
sunssfpoint of that name, looked up the way the decoder does it; the stored integer isvalue / 10^sf, rounded to the nearest integer with halves away from zero. Without thesunssfpoint 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 auint16, 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,
countandsunssfpoints; a symbol name or a number for enums; a[]stringof bit names or a number for bitfields; strings forstring,ipaddr,ipv6addrandeui48; or, for any point, a[]uint16with exactly its registers. - Groups. Count points such as
NPtorNCrvare filled in from the number of instances you add. Groups with fewer instances than an explicit count are completed with not-implemented ones.
Updates and client writes
Section titled “Updates and client writes”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.
Device files
Section titled “Device files”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 andnull, 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 bydevicefile.Schema(), for editors and other tools.
Encoding without I/O
Section titled “Encoding without I/O”values := dm.Values() // a decoded model back into Valuesregs, err := sunspec.EncodeModel(registry.ByID(103), values) // ID and length registers, then the dataEncodeModel 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.
Errors
Section titled “Errors”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.DecodeErrorif 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.
Limitations
Section titled “Limitations”- The client side only reads.
Devicedoes 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, andEncodeModelgives 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
Serveris 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.
SunSpec in go-modbus
Section titled “SunSpec in go-modbus”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.