Skip to content

Connecting devices

A connector is the thing that turns an IEEE 2030.5 control into whatever your device actually speaks. The client resolves a device's LFDI to its connector, then calls methods on it.

SunSpec Modbus

The connector that ships with the package. It speaks the SunSpec 700-series models over three transports:

from py20305.connectors.config import SunSpecDeviceConfig

# Modbus TCP
SunSpecDeviceConfig(lfdi=LFDI, host="192.168.1.50", port=502, unit_id=1)

# Modbus RTU over serial
SunSpecDeviceConfig(lfdi=LFDI, transport="rtu", serial_port="/dev/ttyUSB0", baudrate=9600)

# Modbus TCP wrapped in TLS
SunSpecDeviceConfig(
    lfdi=LFDI, transport="tcp+tls", host="192.168.1.50", port=802,
    ca_path="certs/ca.pem", cert_path="certs/client.pem", key_path="certs/client.key",
)

Install it with the sunspec extra.

CSIP-AUS operating envelope limits

Of the four CSIP-AUS limits, the SunSpec connector enforces the generation limit, opModGenLimW, and the load limit, opModLoadLimW.

The generation limit is converted to a percent of the device's WMax (model 702, or WMaxRtg where WMax is not implemented) and written to WMaxLimPct in model 704. When opModMaxLimW is active too, the more restrictive of the two is written. The device must implement WMaxLimPct; one that does not answers the control as not supported.

The load limit is the maximum charge rate of a storage device, written in watts to WChaRteMax in model 702. When opModMaxLimWAbsorb is active too, the lower of the two is written. A limit above the device's WChaRteMaxRtg does not bind and is written as the rating. The device must declare charge-rate limiting (WChaRteMax implemented and a non-zero WChaRteMaxRtg); one that does not answers the control as not supported.

WChaRteMax is a setting with no enable, so a limit is ended by writing a value. Clearing one limit while the other is active writes the other's value. When no charge limit remains, the connector writes WChaRteMaxRtg, the value for no limit, so a 0 W limit does not leave a battery unable to charge after its event. It does so on every clear, including one for a limit it has no record of, so a clear after a connector restart still releases the battery. A DefaultDERControl that carries a charge limit then sets its own value as usual. A device that does not declare charge-rate limiting is never written to. The discharge-rate setting written by opModMaxLimWInject is not released yet.

doeModesSupported and doeModesEnabled name only the limits the device can take:

Value Device
0C Implements WMaxLimPct and declares charge-rate limiting, as a storage inverter does
04 Implements WMaxLimPct only, as a PV inverter does
08 Declares charge-rate limiting but does not implement WMaxLimPct
00 Neither: no CSIP-AUS limit can be applied

The connector treats a limit as applying to the one inverter it controls. Where a single EndDevice stands for several inverters, each connector is handed the full value; dividing a shared limit between them is up to whatever dispatches the control, not this connector.

The export and import limits (opModExpLimW, opModImpLimW) constrain power at the site's connection point, which an inverter cannot measure. The connector leaves both out of doeModesSupported and does not apply them.

The print connector

Logs what it would have done instead of doing it. Needs no hardware, which makes it the right thing to point at when bringing up a server connection for the first time.

from py20305.connectors.config import PrintDemoDeviceConfig

PrintDemoDeviceConfig(lfdi=LFDI, description="bench test")

Writing your own

Subclass BaseConnector and override only the modes your device supports. Everything you don't override is a silent no-op, which matches the physical reality that not every device does every IEEE 2030.5 mode.

from typing import Any
from py20305.connectors.base import BaseConnector, ConnectorPayload

class MyInverter(BaseConnector):
    connector_name = "MyInverter"
    der_type = 83  # combined PV + storage

    async def fetch_monitoring(self) -> dict[str, Any]:
        return {"W": await self._read_power(), "Hz": await self._read_freq()}

    async def update_p_lim_w(self, params: ConnectorPayload) -> None:
        await self._write_limit(params["value"])

fetch_monitoring returns raw, unscaled values keyed by quantity. Omit a key (or return None) for anything the device does not measure; it is left out of the readings rather than posted as zero.

Keys Quantity
W, Var, VA, A, PF, Hz, V System totals; V is the line-to-line average
ACType SunSpec model 701 enum: 0 single-phase, 1 split-phase, 2 three-phase
WL1..AL3 (W, Var, V, PF, VA, A per line) Per-line values; VL1..VL3 are line-to-neutral
VL1L2, VL2L3, VL3L1 Phase-to-phase voltage; posted only with ACType 1 (VL1L2) or 2 (all three)
WHAvail State of Energy, in Wh

Signs are a requirement on every connector, referenced to the DER as in SunSpec model 701: W is positive for generation and negative for absorption, and Var is positive for reactive power injection (over-excited) and negative for absorption. The SIGNED_LOAD_CONVENTION reading profile negates both, so a connector that reports another convention posts the wrong sign.

Point a device at it by import path — no registration needed:

from py20305.connectors.config import CustomDeviceConfig

CustomDeviceConfig(
    lfdi=LFDI,
    class_path="myproject.connectors.MyInverter",
    init_kwargs={"address": "192.168.1.50"},
)

Connectors that need shared state

class_path constructs your connector with plain keyword arguments. When that is not enough — a connection pool shared across devices, a client for a transport this package knows nothing about, a configuration model of your own — supply a factory_resolver instead.

It is consulted for every device before the built-in resolution runs. Return a factory to claim the device, or None to let the package handle it:

from py20305.connectors.registry import ConnectorConfigRegistry

pool = MyConnectionPool()

def resolve(device):
    if getattr(device, "type", None) == "my-transport":
        return lambda: MyConnector(pool, device.lfdi)
    return None

registry = ConnectorConfigRegistry(devices, factory_resolver=resolve)

The factory is invoked lazily, exactly like a bundled connector's — so it may close over expensive state without constructing it up front, and it inherits the same permanent-failure caching and single-flight resolution.

Errors

Two vocabularies, and the distinction matters because IEEE 2030.5 makes the head-end care about it:

  • py20305.connectors.errors — the transport broke. ConnectorConnectionError (with a permanent flag), ConnectorTimeoutError.
  • py20305.connectors.control_errors — the transport was fine and the control still did not happen. The device is offline, the mode is unsupported, the value is out of range, or the site declined.

Each maps to a specific IEEE 2030.5 Table 31 response status. Raise the one that describes what actually happened and the right code reaches the server.

The write path

Every control that reaches a device goes through one funnel, whether it came from a translated DERControl, a default-control fallback, a comms-loss clear, or a caller naming a control directly. Two seams hang off that funnel, and both are optional: omit them and the dispatcher behaves as though neither existed.

Naming a control directly

apply_control takes a DERControl and translates it. A caller that already knows which control it wants — a protocol server serving register writes, for instance — uses apply_operation instead:

await dispatcher.apply_operation(
    lfdi, "p_lim", {"p_lim_mode_enable": 1, "p_lim_w": 80.0},
    origin="register_server",
)

It goes through the same funnel, so everything an event-driven write gets applies: the command is recorded with its origin, and a failure is recorded as rejected and re-raised. Reaching a connector directly would bypass both.

Naming an origin

An origin is a string, and the caller chooses it. The dispatcher hands it to the gate and to the observer and prints it in diagnostics, and does nothing else with it, so an application's own interfaces need no entry anywhere in this package. Pick one name per interface and use it in all three places: the call above, your CommandGate, and whatever reads the audit trail.

CommandOrigin holds only the origins this package produces: IEEE2030_5 for a control from the utility server, DDERC_REAPPLY for a default control reapplied when an event ends, and COMMS_LOSS for the fallback on a loss of communications. They are strings too, so a gate compares them like any other:

def may_command(self, device: str, origin: str) -> bool:
    if origin in (CommandOrigin.DDERC_REAPPLY, CommandOrigin.COMMS_LOSS):
        origin = CommandOrigin.IEEE2030_5      # the same interface acting
    return self.commander_for(device) == origin

Deny an origin your gate does not recognize. A new write path should have to say who owns it, and a mistyped name then fails closed where it would otherwise command.

Unlike a server-issued control, a refusal here raises CommandNotPermittedError. This caller named one control and has somewhere to put the answer -- a protocol server owes its own client the difference between a write that failed and one that was not permitted, and cannot report either from silence.

Who may command

An application serving more than one command interface usually intends only one of them to command any given device. Deciding that in its own configuration leaves it unenforced here, because every interface still reaches the dispatcher.

CommandGate is how the dispatcher asks. Implement may_command(device, origin) and pass it as command_gate:

class OneCommanderPerDevice:
    def may_command(self, device: str, origin: str) -> bool:
        return self.commander_for(device) == origin

Which interface holds authority is the application's concern, not this package's, so the dependency points that way — as it does for CommandObserver. Omitted, every origin may command everything, which is right for a single-interface consumer. That includes an origin nothing claims, so with no gate a mistyped name commands like a correct one.

A refused server-issued control is reported through diagnostics and dropped rather than raised: an interface posting to a device another one commands is a configuration being honored, not a fault, and raising would make every such control an error for the event engine to interpret. Nothing is recorded for it either, because a command that never left must not appear in the audit trail.

One case is deliberately ungated. Authority is held per device, so a control the resolver cannot name a device for has no rule to apply -- an href that resolves to a connector but not to an LFDI. Those writes proceed, and say so at debug level. Denying them would fail closed by accident rather than by decision, and would drop writes that worked before the gate existed.

What counts as an implemented mode

BaseConnector declares each control mode as a concrete method returning None, so a presence check finds one whether or not the connector implements it. The dispatcher therefore distinguishes two ways a mode can be unsupported, because only one of them is anybody's mistake:

  • The connector inherits the base no-op. It has no register for that mode and never claimed one — expected, and skipped with a debug line.
  • Nothing resolves at all. For a connector that supplies modes dynamically from a live offer, that means the offer is missing something it should carry, which is actionable and reported as a warning.

Overriding the method is what support means. A connector that overrides nothing is not silently credited with accepting every command.

Reacting to the schedule

The write path delivers setpoints. Separately, the client pushes a connector the forward-looking schedule behind those setpoints, so logic that wants to plan (an optimizer, a price-aware dispatcher) has it without polling.

Override on_schedule_notification, or one of the per-stream convenience hooks it fans out to. Each is informational: an exception raised in one is logged and swallowed, never affecting dispatch, and none of them apply a setpoint, which stays the update_* path's job.

Hook Fires on
notification_control a scheduled DERControl was added, updated, activated, superseded, cancelled or completed
notification_default_baseline the program's DefaultDERControl baseline was added or changed
notification_doe a Dynamic Operating Envelope (export / import / generation / load watt-limit) changed
notification_price the active pricing interval changed (Pricing function set)

notification_drlc and notification_flow_reservation exist for streams the client does not emit yet.

Each hook receives a ScheduleNotification: the stream, the transition and status that fired it, the control's mrid, program_href and primacy, the post-randomization start / duration / end, the affected_lfdis the event scopes to, the randomization window, and a stream-specific payload. For price the payload carries the tariff profile's price multiplier and the active interval's consumption blocks; the relay is global and does not actuate, so the hook decides what the price means for the device, for example curtailing export when the price goes negative.