Sunspec Client ExampleΒΆ

from pymodbus.constants import Endian
from pymodbus.client.sync import ModbusTcpClient
from pymodbus.payload import BinaryPayloadDecoder
from twisted.internet.defer import Deferred


#---------------------------------------------------------------------------#
# Logging
#---------------------------------------------------------------------------#
import logging
_logger = logging.getLogger(__name__)
_logger.setLevel(logging.DEBUG)
logging.basicConfig()


# --------------------------------------------------------------------------- # 
# Sunspec Common Constants
# --------------------------------------------------------------------------- # 
class SunspecDefaultValue(object):
    """ A collection of constants to indicate if
    a value is not implemented.
    """
    Signed16        = 0x8000
    Unsigned16      = 0xffff
    Accumulator16   = 0x0000
    Scale           = 0x8000
    Signed32        = 0x80000000
    Float32         = 0x7fc00000
    Unsigned32      = 0xffffffff
    Accumulator32   = 0x00000000
    Signed64        = 0x8000000000000000
    Unsigned64      = 0xffffffffffffffff
    Accumulator64   = 0x0000000000000000
    String          = '\x00'


class SunspecStatus(object):
    """ Indicators of the current status of a
    sunspec device
    """
    Normal  = 0x00000000
    Error   = 0xfffffffe
    Unknown = 0xffffffff


class SunspecIdentifier(object):
    """ Assigned identifiers that are pre-assigned
    by the sunspec protocol.
    """
    Sunspec = 0x53756e53


class SunspecModel(object):
    """ Assigned device indentifiers that are pre-assigned
    by the sunspec protocol.
    """
    #---------------------------------------------
    # 0xx Common Models
    #---------------------------------------------
    CommonBlock                              = 1
    AggregatorBlock                          = 2

    #---------------------------------------------
    # 1xx Inverter Models
    #---------------------------------------------
    SinglePhaseIntegerInverter               = 101
    SplitPhaseIntegerInverter                = 102
    ThreePhaseIntegerInverter                = 103
    SinglePhaseFloatsInverter                = 103
    SplitPhaseFloatsInverter                 = 102
    ThreePhaseFloatsInverter                 = 103

    #---------------------------------------------
    # 2xx Meter Models
    #---------------------------------------------
    SinglePhaseMeter                         = 201
    SplitPhaseMeter                          = 201
    WyeConnectMeter                          = 201
    DeltaConnectMeter                        = 201

    #---------------------------------------------
    # 3xx Environmental Models
    #---------------------------------------------
    BaseMeteorological                       = 301
    Irradiance                               = 302
    BackOfModuleTemperature                  = 303
    Inclinometer                             = 304
    Location                                 = 305
    ReferencePoint                           = 306
    BaseMeteorological                       = 307
    MiniMeteorological                       = 308

    #---------------------------------------------
    # 4xx String Combiner Models             
    #---------------------------------------------
    BasicStringCombiner                      = 401
    AdvancedStringCombiner                   = 402

    #---------------------------------------------
    # 5xx Panel Models
    #---------------------------------------------
    PanelFloat                               = 501
    PanelInteger                             = 502

    #---------------------------------------------
    # 641xx Outback Blocks
    #---------------------------------------------
    OutbackDeviceIdentifier                  = 64110
    OutbackChargeController                  = 64111
    OutbackFMSeriesChargeController          = 64112
    OutbackFXInverterRealTime                = 64113
    OutbackFXInverterConfiguration           = 64114
    OutbackSplitPhaseRadianInverter          = 64115
    OutbackRadianInverterConfiguration       = 64116
    OutbackSinglePhaseRadianInverterRealTime = 64117
    OutbackFLEXNetDCRealTime                 = 64118
    OutbackFLEXNetDCConfiguration            = 64119
    OutbackSystemControl                     = 64120

    #---------------------------------------------
    # 64xxx Vender Extension Block
    #---------------------------------------------
    EndOfSunSpecMap                          = 65535

    @classmethod
    def lookup(klass, code):
        """ Given a device identifier, return the
        device model name for that identifier

        :param code: The device code to lookup
        :returns: The device model name, or None if none available
        """
        values = dict((v, k) for k, v in klass.__dict__.iteritems()
            if not callable(v))
        return values.get(code, None)


class SunspecOffsets(object):
    """ Well known offsets that are used throughout
    the sunspec protocol
    """
    CommonBlock             = 40000
    CommonBlockLength       = 69
    AlternateCommonBlock    = 50000


# --------------------------------------------------------------------------- # 
# Common Functions
# --------------------------------------------------------------------------- # 
def defer_or_apply(func):
    """ Decorator to apply an adapter method
    to a result regardless if it is a deferred
    or a concrete response.

    :param func: The function to decorate
    """
    def closure(future, adapt):
        if isinstance(future, Deferred):
            d = Deferred()
            future.addCallback(lambda r: d.callback(adapt(r)))
            return d
        return adapt(future)
    return closure


def create_sunspec_sync_client(host):
    """ A quick helper method to create a sunspec
    client.

    :param host: The host to connect to
    :returns: an initialized SunspecClient
    """
    modbus = ModbusTcpClient(host)
    modbus.connect()
    client = SunspecClient(modbus)
    client.initialize()
    return client


# --------------------------------------------------------------------------- # 
# Sunspec Client
# --------------------------------------------------------------------------- # 
class SunspecDecoder(BinaryPayloadDecoder):
    """ A decoder that deals correctly with the sunspec
    binary format.
    """

    def __init__(self, payload, byteorder):
        """ Initialize a new instance of the SunspecDecoder

        .. note:: This is always set to big endian byte order
        as specified in the protocol.
        """
        byteorder = Endian.Big
        BinaryPayloadDecoder.__init__(self, payload, byteorder)

    def decode_string(self, size=1):
        """ Decodes a string from the buffer

        :param size: The size of the string to decode
        """
        self._pointer += size
        string = self._payload[self._pointer - size:self._pointer]
        return string.split(SunspecDefaultValue.String)[0]


class SunspecClient(object):

    def __init__(self, client):
        """ Initialize a new instance of the client

        :param client: The modbus client to use
        """
        self.client = client
        self.offset = SunspecOffsets.CommonBlock

    def initialize(self):
        """ Initialize the underlying client values

        :returns: True if successful, false otherwise
        """
        decoder  = self.get_device_block(self.offset, 2)
        if decoder.decode_32bit_uint() == SunspecIdentifier.Sunspec:
            return True
        self.offset = SunspecOffsets.AlternateCommonBlock
        decoder  = self.get_device_block(self.offset, 2)
        return decoder.decode_32bit_uint() == SunspecIdentifier.Sunspec

    def get_common_block(self):
        """ Read and return the sunspec common information
        block.

        :returns: A dictionary of the common block information
        """
        length  = SunspecOffsets.CommonBlockLength
        decoder = self.get_device_block(self.offset, length)
        return {
            'SunSpec_ID':       decoder.decode_32bit_uint(),
            'SunSpec_DID':      decoder.decode_16bit_uint(),
            'SunSpec_Length':   decoder.decode_16bit_uint(),
            'Manufacturer':     decoder.decode_string(size=32),
            'Model':            decoder.decode_string(size=32),
            'Options':          decoder.decode_string(size=16),
            'Version':          decoder.decode_string(size=16),
            'SerialNumber':     decoder.decode_string(size=32),
            'DeviceAddress':    decoder.decode_16bit_uint(),
            'Next_DID':         decoder.decode_16bit_uint(),
            'Next_DID_Length':  decoder.decode_16bit_uint(),
        }

    def get_device_block(self, offset, size):
        """ A helper method to retrieve the next device block

        .. note:: We will read 2 more registers so that we have
        the information for the next block.

        :param offset: The offset to start reading at
        :param size: The size of the offset to read
        :returns: An initialized decoder for that result
        """
        _logger.debug("reading device block[{}..{}]".format(offset, offset + size))
        response = self.client.read_holding_registers(offset, size + 2)
        return SunspecDecoder.fromRegisters(response.registers)

    def get_all_device_blocks(self):
        """ Retrieve all the available blocks in the supplied
        sunspec device.

        .. note:: Since we do not know how to decode the available
        blocks, this returns a list of dictionaries of the form:

            decoder: the-binary-decoder,
            model:   the-model-identifier (name)

        :returns: A list of the available blocks
        """
        blocks = []
        offset = self.offset + 2
        model  = SunspecModel.CommonBlock
        while model != SunspecModel.EndOfSunSpecMap:
            decoder = self.get_device_block(offset, 2)
            model   = decoder.decode_16bit_uint()
            length  = decoder.decode_16bit_uint()
            blocks.append({
                'model' : model,
                'name'  : SunspecModel.lookup(model),
                'length': length,
                'offset': offset + length + 2
            })
            offset += length + 2
        return blocks


#------------------------------------------------------------
# A quick test runner
#------------------------------------------------------------
if __name__ == "__main__":
    client = create_sunspec_sync_client("YOUR.HOST.GOES.HERE")

    # print out all the device common block
    common = client.get_common_block()
    for key, value in common.iteritems():
        if key == "SunSpec_DID":
            value = SunspecModel.lookup(value)
        print("{:<20}: {}".format(key, value))

    # print out all the available device blocks
    blocks = client.get_all_device_blocks()
    for block in blocks:
        print(block)

    client.client.close()