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 apermanentflag),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.