Skip to content

Discovery and generation

A SunSpec device advertises its models after a "SunS" marker. Each model has a two-register header containing its ID and data length. Model ID 0xFFFF terminates the chain.

scan returns each model ID and its locations:

from modbus_connection.model.sunspec import scan
models = await scan(unit, 40000) # SunSpecModels: dict[int, list[SunSpecModel]]

base_address is the zero-based marker address. SunSpec defines 0, 40000, and 50000 as possible locations. Each SunSpecModel contains the model ID, header address, and data length. A model ID can occur more than once.

The result is a SunSpecModels: a plain dict keyed by model ID, with three lookups on top:

models.first(103, 101) # the first ID present, in preference order, or None
models.chain # every model in chain order
models.at(40188) # the model whose header sits there, or None

chain tells repeats of one ID apart. For example, a SolarEdge meter is identified by the model 1 immediately before it rather than by its own ID.

A model’s length is the data length its header reports. span adds the two header registers. span is therefore both the count that reads the whole block and the step to the next header.

Subclass SunSpecComponent, declare fields relative to the model header, and construct it with a discovered model:

from modbus_connection.model.sunspec import SunSpecComponent, sunssf, uint16
class Inverter(SunSpecComponent):
a = uint16(2, scale_register=6)
a_sf = sunssf(6)
# a three-phase inverter if the device has one, else the single-phase model
if (found := models.first(103, 101)) is not None:
inverter = Inverter(unit, found)

The header occupies offsets 0 and 1; data begins at offset 2. SunSpecComponent verifies the header after every update. If the device moves a model, it raises SunSpecMapShiftError. Scan again and construct new components.

You do not have to write those component classes by hand. SunSpec publishes its standard model definitions as JSON in sunspec/models. Generate component classes from model IDs or local model_N.json files:

Terminal window
python -m modbus_connection.model.sunspec.generate 1 103 160 -o sunspec_models.py

Without -o, the module writes the generated source to standard output. The result is ordinary source intended as a starting point. Review it against the manufacturer’s implementation and commit the adjusted classes to the device library.

The output contains a SunSpecComponent subclass for each model, fields for its points, enum and flag types, and statically expressible repeated groups. Class names come from the model’s group name, with the model ID added when names collide. Each point’s label and description become its attribute docstring.

class OperatingState(IntEnum):
OFF = 1
SLEEPING = 2
class InverterThreePhase(SunSpecComponent):
"""SunSpec model 103: Inverter (Three Phase)."""
a = uint16(2, scale_register=6, unit="A")
"""Amps. AC Current."""
st = enum16(38, OperatingState)
"""Operating State."""

A block the device sizes, like model 705’s curves, is sized at poll time by default. Pass --count when you know the values your device reports, as a device library does:

Terminal window
python -m modbus_connection.model.sunspec.generate 705 707 \
--count 705:NCrv=3 --count 705:NPt=4 \
--count 707:NCrvSet=2 --count 707:NPt=5

The counts are then baked into fixed-count groups, which fold into the model’s read instead of adding a pass. A device that reports different counts has a different model length, and SunSpecComponent rejects that header on the first read.

A curve is a repeated block of writable points, and a device expects it whole. When every point of a repeated block is writable, the generator adds a method to the block’s owner that writes the block in one request:

class DERVoltVarCrv(Component):
pt = repeating_group(4, DERVoltVarCrvPt, stride=2)
async def write_pt(self, values: Sequence[Mapping[str, Any]]) -> None:
"""Write consecutive 'Pt' instances in one request.
Each mapping sets one instance and must set every field:
v, var. Instances past ``values`` are untouched.
"""
await write_block(self, "pt", values)

Pass one mapping per point. Each mapping must set every field, because the block goes to the device as one run of registers:

await volt_var.crv[1].write_pt(
[
{"v": 92.0, "var": 30.0},
{"v": 98.0, "var": 0.0},
{"v": 102.0, "var": 0.0},
{"v": 108.0, "var": -30.0},
]
)

The registers go out as one FC16, and each distinct scale register is read once. This four-point curve costs one write and two reads. The method is named after its block because one class can own several: model 704’s controls block gets write_pfw_inj, write_pfw_inj_rvrt, write_pfw_abs and write_pfw_abs_rvrt.

The write_block helper is emitted once into the generated module. It is generated source rather than library API. Adjust it with the classes that call it.

A block gets no method when it contains a nested block, a read-only point, or a point that is never written, such as a scale factor or an accumulator.

Writing the registers does not activate a curve. Write into a curve whose read_only point reports read-write access, then request it with adpt_crv_req.