Sunspec

What is SunSpec?

SunSpec is a standard for getting data from devices that are related to solar, battery and other electricity related systems. Go to the SunSpec Alliance website if you really want the details.

At the technical side SunSpec is a meta schema for Modbus that is used by many different devices from many vendors.

SunSpec is essentially collection of partial modbus mappings (they call Models) and each Model describes the meaningful values for a specific capability. There are models for many capabilities: being a solar inverter, being a battery, having IPv4 network support, …

Each actual SunSpec device has a different set of models so it depends on the actual device which of these models are present in the system AND at which address they live. Reality is that a software update by a vendor of a device can change this list of models.

The list of all possible models has been published by the SunSpec Alliance on github: https://github.com/sunspec/models

How to get the Modbus Schema for a SunSpec device

To read the data from a SunSpec device you first need to ask which models it has and at which address they live.

The SunSpec library I created does just that:

  • Interrogate the device which models are present and at what address
  • Use that list to convert the SunSpec models into a Modbus Schema for the device at that point in time.
<dependency>
  <groupId>nl.basjes.sunspec</groupId>
  <artifactId>sunspec-device</artifactId>
  <version>0.4.1</version>
</dependency>
// Read the schema by interrogating the device.
// In general this will result in many blocks and hundreds of Fields
// (My Solar inverter has 19 Blocks and 602 Fields).
val device = SunspecDevice.generate(
    modbusDevice, // Interrogate this device
    "Demo",       // Give it a name (Optional)
    true,         // true = Skip generating a fake Schema Block for a unknown models
) ?: throw ModbusException("Unable to generate SunSpec Schema")
Persisting a SunSpec schema has risks!

Persisting the schema of a SunSpec device to a Yaml file or to generated code is a risky operation. You cannot assume that this code is usable for other devices also. You cannot even assume it is usable on the SAME device after a software update. If you want to do this then it is recommended to include checks in the code if all the Fields in “Model 1” (the identification of the device including brand, type, serial number and software version) are the values that match the device for which it was generated.

Lists of …

Some SunSpec models define repeating groups of Fields.

Original SunSpec models like have a fixed block of Fields and then some of them have 1 or more instances of a repeated group of Fields.

In those cases you will see that I have named those Fields with essentially an array index (starting at 0 !!) in the name of the field.

Info

These indexes are separated by ‘_’ so it is possible to parse it if you really need to.

Take for example model 160 in which you may find fields like Module_0_ID and Module_5_ID

Lists of Lists of …

In the newer models (like the 7xx series) the SunSpec goes a level deeper. Here you can find multiple nesting levels.

In for example model 710 (“DER high frequency trip model.”).

  • In addition to the base fields there are multiple “Curves”.
  • Each Curve has a “ReadOnly” indicator Field and 3 sub parts (“MustTrip”, “MayTrip” and “MomCess”).
    • Note that these do NOT have an array index because they are fixed parts!
  • Each subpart has an array of points consisting of a frequency (Hz) and a time (Tms).

Some of the fields you get from this (with the values from the test case I have):

'Ena':                       [ 'ENABLED' ]
'AdptCrvReq':                [ '0' ]
'AdptCrvRslt':               [ 'IN_PROGRESS' ]

'Crv_0_ReadOnly':            [ 'R' ]
'Crv_0_MustTrip_ActPt':      [ '5' ]
'Crv_0_MustTrip_Pt_0_Hz':    [ '63.000' ]
'Crv_0_MustTrip_Pt_0_Tms':   [ '0.160' ]
'Crv_0_MustTrip_Pt_1_Hz':    [ '62.000' ]
'Crv_0_MustTrip_Pt_1_Tms':   [ '0.160' ]
'Crv_0_MustTrip_Pt_2_Hz':    [ '62.000' ]
'Crv_0_MustTrip_Pt_2_Tms':   [ '300.000' ]
'Crv_0_MustTrip_Pt_3_Hz':    [ '61.200' ]
'Crv_0_MustTrip_Pt_3_Tms':   [ '300.000' ]
'Crv_0_MustTrip_Pt_4_Hz':    [ '61.200' ]
'Crv_0_MustTrip_Pt_4_Tms':   [ '400.000' ]

'Crv_0_MayTrip_ActPt':       []
'Crv_0_MayTrip_Pt_0_Hz':     []
'Crv_0_MayTrip_Pt_0_Tms':    []
'Crv_0_MayTrip_Pt_1_Hz':     []
'Crv_0_MayTrip_Pt_1_Tms':    []
'Crv_0_MayTrip_Pt_2_Hz':     []
'Crv_0_MayTrip_Pt_2_Tms':    []
'Crv_0_MayTrip_Pt_3_Hz':     []
'Crv_0_MayTrip_Pt_3_Tms':    []
'Crv_0_MayTrip_Pt_4_Hz':     []
'Crv_0_MayTrip_Pt_4_Tms':    []

'Crv_0_MomCess_ActPt':       []
'Crv_0_MomCess_Pt_0_Hz':     []
'Crv_0_MomCess_Pt_0_Tms':    []
'Crv_0_MomCess_Pt_1_Hz':     []
'Crv_0_MomCess_Pt_1_Tms':    []
'Crv_0_MomCess_Pt_2_Hz':     []
'Crv_0_MomCess_Pt_2_Tms':    []
'Crv_0_MomCess_Pt_3_Hz':     []
'Crv_0_MomCess_Pt_3_Tms':    []
'Crv_0_MomCess_Pt_4_Hz':     []
'Crv_0_MomCess_Pt_4_Tms':    []

'Crv_1_ReadOnly':            [ 'RW' ]
'Crv_1_MustTrip_ActPt':      [ '5' ]
'Crv_1_MustTrip_Pt_0_Hz':    [ '63.000' ]
'Crv_1_MustTrip_Pt_0_Tms':   [ '0.160' ]
'Crv_1_MustTrip_Pt_1_Hz':    [ '62.000' ]
'Crv_1_MustTrip_Pt_1_Tms':   [ '0.160' ]
'Crv_1_MustTrip_Pt_2_Hz':    [ '62.000' ]
'Crv_1_MustTrip_Pt_2_Tms':   [ '300.000' ]
'Crv_1_MustTrip_Pt_3_Hz':    [ '61.200' ]
'Crv_1_MustTrip_Pt_3_Tms':   [ '300.000' ]
'Crv_1_MustTrip_Pt_4_Hz':    [ '61.200' ]
'Crv_1_MustTrip_Pt_4_Tms':   [ '400.000' ]

'Crv_1_MayTrip_ActPt':       []
'Crv_1_MayTrip_Pt_0_Hz':     []
'Crv_1_MayTrip_Pt_0_Tms':    []
'Crv_1_MayTrip_Pt_1_Hz':     []
'Crv_1_MayTrip_Pt_1_Tms':    []
'Crv_1_MayTrip_Pt_2_Hz':     []
'Crv_1_MayTrip_Pt_2_Tms':    []
'Crv_1_MayTrip_Pt_3_Hz':     []
'Crv_1_MayTrip_Pt_3_Tms':    []
'Crv_1_MayTrip_Pt_4_Hz':     []
'Crv_1_MayTrip_Pt_4_Tms':    []

'Crv_1_MomCess_ActPt':       []
'Crv_1_MomCess_Pt_0_Hz':     []
'Crv_1_MomCess_Pt_0_Tms':    []
'Crv_1_MomCess_Pt_1_Hz':     []
'Crv_1_MomCess_Pt_1_Tms':    []
'Crv_1_MomCess_Pt_2_Hz':     []
'Crv_1_MomCess_Pt_2_Tms':    []
'Crv_1_MomCess_Pt_3_Hz':     []
'Crv_1_MomCess_Pt_3_Tms':    []
'Crv_1_MomCess_Pt_4_Hz':     []
'Crv_1_MomCess_Pt_4_Tms':    []

Example

Two examples on how this can be used in a Kotlin Script

A basic example to simply dump everything to the console

GetSunSpecFullDump.main.kts
#!/usr/bin/env kotlin

// Include the needed libraries
@file:DependsOn("nl.basjes.modbus:modbus-api-plc4j:0.6.0")
@file:DependsOn("nl.basjes.sunspec:sunspec-device:0.4.1")

// Regular Kotlin import statements
import nl.basjes.modbus.device.api.MODBUS_STANDARD_TCP_PORT
import nl.basjes.modbus.device.exception.ModbusException
import nl.basjes.modbus.device.plc4j.ModbusDevicePlc4j
import nl.basjes.modbus.schema.toTable
import nl.basjes.sunspec.SUNSPEC_STANDARD_UNITID
import nl.basjes.sunspec.device.SunspecDevice

// The hostname to connect to
val modbusIp          = "sunspec.iot.basjes.nl"

// Use the standards for SunSpec to connect to the device
val modbusPort        = MODBUS_STANDARD_TCP_PORT
val modbusUnit        = SUNSPEC_STANDARD_UNITID

print("Modbus: Connecting...")
// Connect to the real Modbus device over TCP using the Apache PLC4J library
ModbusDevicePlc4j("modbus-tcp:tcp://${modbusIp}:${modbusPort}?unit-identifier=${modbusUnit}")
    .use { modbusDevice ->
    println(" done")

    // Read the schema by interrogating the device (so no Yaml file).
    // In general this will result in many blocks and hundreds of Fields (My Solar inverter has 19 Blocks and 602 Fields).
    val device = SunspecDevice.generate(
        modbusDevice, // Interrogate this device
        "Demo",       // Give it a name (Optional)
        true,         // true = Skip generating a fake Schema Block for a unknown models
    ) ?: throw ModbusException("Unable to generate SunSpec Schema")

    println("The SchemaDevice for this specific SunSpec device has ${device.blocks.size} Blocks with a total of ${device.fields.size} fields.")

    // Connect this Schema Device to the physical device (via Plc4J in this case)
    device.connect(modbusDevice)

    // Fetch the registers for all defined Fields
    device.updateAll()

    // Output the results as a table ( Where "----" is a not retrieved register, "xxxx" is a read error )
    // Because this is SunSpec you will see a table with hundreds of Fields and only a few have a value.
    println(device.toTable(onlyUseFullFields = false, includeRawDataAndMappings = true))
}

A basic example to get specific values

GetSunSpec.main.kts
#!/usr/bin/env kotlin

// Include the needed libraries
@file:DependsOn("nl.basjes.modbus:modbus-api-plc4j:0.6.0")
@file:DependsOn("nl.basjes.sunspec:sunspec-device:0.4.1")

// Regular Kotlin import statements
import nl.basjes.modbus.device.api.MODBUS_STANDARD_TCP_PORT
import nl.basjes.modbus.device.exception.ModbusException
import nl.basjes.modbus.device.plc4j.ModbusDevicePlc4j
import nl.basjes.modbus.schema.ReturnType
import nl.basjes.modbus.schema.get
import nl.basjes.modbus.schema.toTable
import nl.basjes.modbus.schema.utils.StringTable
import nl.basjes.sunspec.SUNSPEC_STANDARD_UNITID
import nl.basjes.sunspec.device.SunspecDevice
import java.lang.Thread.sleep

// The hostname to connect to
val modbusIp          = "sunspec.iot.basjes.nl"

// Use the standards for SunSpec to connect to the device
val modbusPort        = MODBUS_STANDARD_TCP_PORT
val modbusUnit        = SUNSPEC_STANDARD_UNITID

print("Modbus: Connecting...")
// Connect to the real Modbus device over TCP using the Apache PLC4J library
ModbusDevicePlc4j("modbus-tcp:tcp://${modbusIp}:${modbusPort}?unit-identifier=${modbusUnit}")
    .use { modbusDevice ->
    println(" done")

    // Read the schema by interrogating the device (so no Yaml file).
    // In general this will result in many blocks and hundreds of Fields (My Solar inverter has 19 Blocks and 602 Fields).
    val device = SunspecDevice.generate(
        modbusDevice, // Interrogate this device
        "Demo",       // Give it a name (Optional)
        true,         // true = Skip generating a fake Schema Block for a unknown models
    ) ?: throw ModbusException("Unable to generate SunSpec Schema")

    println("The SchemaDevice for this specific SunSpec device has ${device.blocks.size} Blocks with a total of ${device.fields.size} fields.")

    // Connect this Schema Device to the physical device (via Plc4J in this case)
    device.connect(modbusDevice)

    // Get a reference to some of the available fields
    val manufacturer = device["Model 1"]["Mn"] ?: throw IllegalArgumentException("Unable to get the \"Model 1\" -> \"Mn\" Field")
    val model        = device["Model 1"]["Md"] ?: throw IllegalArgumentException("Unable to get the \"Model 1\" -> \"Md\" Field")
    val version      = device["Model 1"]["Vr"] ?: throw IllegalArgumentException("Unable to get the \"Model 1\" -> \"Vr\" Field")
    val serialNumber = device["Model 1"]["SN"] ?: throw IllegalArgumentException("Unable to get the \"Model 1\" -> \"SN\" Field")

    val singlePhaseInverterACPower = device["Model 101"]["W"] ?: throw IllegalArgumentException("Unable to get the \"Model 101\" -> \"W\" Field")

    val wantedFields = listOf(manufacturer, model, version, serialNumber, singlePhaseInverterACPower)

    // Real Modbus devices are so very slow that we need to indicate which need to be kept up to date
    wantedFields.forEach { it.need() }

    // For this demo we just do 10 fetches with a ~1 second delay between them.
    for ( i in 0 .. 10) {
        sleep(1000)
        println("== FETCH $i ======================================\n")

        // Now we tell the system all fields must be made up-to-date and we want everything to be updated.
        device.update() // << Here the modbus calls are done.

        // Note that:
        // - We did not ask for the "Opt" field. It WILL be retrieved because the optimizing fetcher calculated that to be more efficient.
        // - On the second loop only the singlePhaseInverterACPower will be updated. The others are marked as immutable and have not changed.


        // For this demo we are putting the values in a table that is printed on the screen.
        // Normally you would push it into a processing and/or storage system (like InfluxDb or Apache IoTDB)
        val table = StringTable()
        table.withHeaders("Block", "ID", "Value", "Unit", "Block Description", "Field Description")

        for (field in wantedFields) {
            table.addRow(
                field.block.id,
                field.id,
                // The produced value will be made available in a strongly typed property of the Field.
                // This allows for a clean use of the data in further calculations.
                when (field.returnType) {
                    ReturnType.LONG       -> field.longValue.toString()
                    ReturnType.DOUBLE     -> field.doubleValue.toString()
                    ReturnType.STRING     -> field.stringValue.toString()
                    ReturnType.STRINGLIST -> field.stringListValue.toString()
                    ReturnType.BOOLEAN    -> TODO("Support for Booleans is not there yet")
                    ReturnType.UNKNOWN    -> "<< D'oh! >>"
                },
                field.unit,
                field.block.description ?: "",
                field.description,
            )
        }
        println(table)
    }
    println("========================================\n")

    // Output the results as a table ( Where "----" is a not retrieved register, "xxxx" is a read error )
    // Because this is SunSpec you will see a table with hundreds of Fields and only a few have a value.
    println(device.toTable(onlyUseFullFields = false, includeRawDataAndMappings = true))
}

To get a sense of what this results in: One of the tables printed in the loop of this code looked like this (my solar panels were making only 190 Watt at that moment):

|-----------+----+--------------+------+----------------------------------------------------------------------------------+----------------------------------------------------------|
| Block     | ID | Value        | Unit | Block Description                                                                | Field Description                                        |
|-----------+----+--------------+------+----------------------------------------------------------------------------------+----------------------------------------------------------|
| Model 1   | Mn | SMA          |      | Common: All SunSpec compliant devices must include this as the first model       | Well known value registered with SunSpec for compliance. |
| Model 1   | Md | SB3.6-1AV-41 |      | Common: All SunSpec compliant devices must include this as the first model       | Manufacturer specific value (32 chars).                  |
| Model 1   | Vr | 4.01.15.R    |      | Common: All SunSpec compliant devices must include this as the first model       | Manufacturer specific value (16 chars).                  |
| Model 1   | SN | 3005067415   |      | Common: All SunSpec compliant devices must include this as the first model       | Manufacturer specific value (32 chars).                  |
| Model 101 | W  | 190.0        | W    | Inverter (Single Phase): Include this model for single phase inverter monitoring | AC Power.                                                |
|-----------+----+--------------+------+----------------------------------------------------------------------------------+----------------------------------------------------------|