Serving an outstation¶
Early development
The protocol layers, the listener, controls and the event path are
implemented and tested: a master reads classes 1 to 3, confirms what it was
sent, and is told through the indication bits what is still waiting. An
answer too large for one fragment is a conversation:
the master confirms each fragment and the next follows. Unsolicited
responses are there for a session built to send them, and off otherwise. The first release is
0.1.0 and the API is not stable: while the major version is 0, a minor
bump may carry a breaking change.
The shape of it¶
Three pieces fit together:
- A read provider you write, which turns a master's request into encoded objects. This is where your data comes from.
- A
Session, which owns the protocol state of one master association and does no I/O at all: octets in, octets out. - An
OutstationServer, which owns the socket.
The session doing no I/O is deliberate. It means every protocol behavior on this page can be tested against literal frames with no listener, no TLS and no event loop.
A minimal outstation¶
import asyncio
from collections.abc import Sequence
from py1815.application import ObjectHeader
from py1815.objects import AnalogPoint, AnalogVariation, analog_flags, analog_range
from py1815.server import OutstationServer
from py1815.session import Session, UnknownObject
POINTS = [
AnalogPoint(10),
AnalogPoint(-20),
AnalogPoint(30),
# A point whose device has gone unreachable keeps its last value, with
# ONLINE cleared and COMM_LOST set, rather than disappearing or reading
# as zero.
AnalogPoint(40, analog_flags(online=False, comm_lost=True)),
AnalogPoint(50),
]
class Provider:
"""Answers reads for the groups this outstation serves."""
def read(self, headers: Sequence[ObjectHeader]) -> bytes:
unknown = sorted({header.group for header in headers} - {30, 60})
if unknown:
# Refused as an unknown object rather than an unsupported
# function: READ is supported, and the master should learn which
# of the two went wrong.
raise UnknownObject(f"no group {unknown} here")
return analog_range(0, POINTS, variation=AnalogVariation.INT32_WITH_FLAG)
async def main() -> None:
session = Session(Provider(), outstation_address=1024, master_address=1)
server = OutstationServer(session, bind="0.0.0.0:20000")
await server.start()
try:
await asyncio.Event().wait()
finally:
await server.stop()
asyncio.run(main())
Point a master at port 20000 and it will read five analog inputs, one of them marked comm-lost.
Values arrive already scaled¶
The library encodes and decodes the wire format. It does not embed a point map and it does not apply scaling: the number you hand it is the number that goes on the wire.
That separation is deliberate. The IEEE 1815.2 tables assign specific indices to specific measurements, and those tables are distributed to DNP Users Group members rather than published, so a library that embedded them could not be shared. See D6.
One connection is the master¶
A second authorized connection displaces the first rather than joining it. Event buffers, confirmation state and unsolicited retry ownership belong to a master association, not to a socket, and the usual cause of a second connection is a master whose socket died without a FIN. Refusing it would leave the outstation unreachable until a timeout it cannot observe.
The session survives the displacement. Its framing state does not: half a frame belongs to the socket that carried it, and completing it with octets from the next connection would splice two conversations into one request.
So a reconnecting master finds the restart indication it has not cleared, and the events it has not read, exactly where it left them. See D7.
Unsolicited responses¶
An outstation can report events without waiting to be polled, once a master has
enabled the classes it wants. It is off unless you turn it on, and a session
with it off answers as a device without the feature does: ENABLE_UNSOLICITED
and DISABLE_UNSOLICITED are both refused as unsupported, with IIN2.0. Many
masters send a disable request when they connect, and will report that refusal.
Turn unsolicited responses on if the master expects to manage them.
from py1815.events import EventBuffers, EventClass
from py1815.objects import AnalogPoint
from py1815.session import Session
buffers = EventBuffers()
session = Session(
Provider(),
events=buffers,
outstation_address=1024,
master_address=1,
unsolicited=True,
unsolicited_confirm_timeout=5.0, # seconds before an unconfirmed one is sent again
unsolicited_retries=None, # how many times; None, the default, is no limit
)
With it on, the session:
- announces a restart with a null unsolicited response, sent again every
unsolicited_confirm_timeoutuntil the master confirms it; - answers a master enabling or disabling classes 1, 2 and 3 with
ENABLE_UNSOLICITEDandDISABLE_UNSOLICITED, and starts every class disabled after a restart; - sends the events of an enabled class in an unsolicited response, and removes
them from the buffers only when the master confirms that response, so an
event is never lost to a confirmation that did not arrive. When the retries
run out the events stay buffered, a class poll reads them, and reporting
starts again at the next event, the next request from the master, a new
connection, or after
unsolicited_resumeseconds (sixty unless set); - holds a read that arrives while an unsolicited response is waiting to be confirmed, and answers it once the confirmation arrives or the wait ends. Turn it on for a master that confirms unsolicited responses: one that ignores them would have every read answered up to the timeout late.
The session still does no I/O. Unsolicited traffic comes from a second method,
session.initiate(), which returns the octets that are due, usually none, and
session.initiate_after(), which says how many seconds until the clock alone
could change that. OutstationServer calls them for you on each connection:
when the master connects, after everything it receives, when the session's
own retry time comes, and every unsolicited_interval (half a second unless
set). Tell it when you have recorded events and they go out at once:
server = OutstationServer(session, bind="0.0.0.0:20000")
await server.start()
# ... in the loop that reads the device:
buffers.record_analog(0, AnalogPoint(power), event_class=EventClass.CLASS_1, deadband=5)
server.notify()
Call notify() from the event loop's own thread; from another, use
loop.call_soon_threadsafe(server.notify). Without it, the next look at the
interval finds the event.
A session you drive yourself, without the listener, is driven the same way:
after a connection is made, after each receive(), after recording events,
and when initiate_after() says, call initiate() and write what it returns
to the master. While there is no connection, do not call it; when one is made,
call connection_reset() and then initiate(), and the master is told at once
what it missed.
The timer that drives the retries only reports. Nothing on that path operates a control or changes an output, however long the master is silent. See D69 to D71.
A master you cannot name in advance¶
A session serves one master address, and drops frames from any other:
An outstation shipped to a site whose controller has not been chosen cannot be
configured that way. Pass None and the session serves whichever master
speaks first on a connection:
Replies go to the address that asked. That address is the master for as long
as the connection lasts: a frame from a second address on the same connection
is dropped, because two masters would interleave over one set of sequence
numbers. A new connection starts again, so a master that comes back under a
different address is served. session.master_address says who is being served
now, and is None until someone has spoken.
Built with unsolicited=True as well, the session reports to the master it is
serving. It sends nothing until a master has spoken on the connection, since
until then there is no address to send to, and the restart is announced after
that master's first request is answered. What a master enabled stands when the
same address comes back on a new connection. A different address starts with
every class disabled and enables what it wants, so no master is sent reports
another one asked for.
Warning
A link address is not authorization, and this makes that plain. With no transport security, any peer that can reach the listener can read from an outstation built this way, and command it if controls are bound. Restrict who can connect with the TLS allow-list below, or with the network.
See D64.
TLS¶
import ssl
context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
context.load_cert_chain("outstation.pem", "outstation.key")
context.load_verify_locations("ca.pem")
context.verify_mode = ssl.CERT_REQUIRED
server = OutstationServer(
session,
bind="0.0.0.0:20000",
ssl_context=context,
authorized_peers=frozenset(
{
"master.example",
"sha256:9a1f...",
}
),
)
Three configurations are refused at construction rather than allowed to run looking secure:
- TLS without an allow-list. Trusting a CA alone authenticates every certificate that CA ever issued.
- TLS without
CERT_REQUIRED. There would be no client certificate, so nothing to check the allow-list against. - An allow-list without TLS. A plaintext connection presents no certificate, so the list could not be applied, and a caller who passed one would believe they had restricted who may connect.
Entries match a certificate's common name, a DNS subject alternative name, or
its SHA-256 fingerprint with the sha256: prefix. Names compare
case-insensitively because DNS does; fingerprints compare exactly. There are no
wildcards, because an allow-list whose entries match things nobody enumerated is
not an allow-list.
Names and fingerprints are matched only against their own kind, so a certificate whose common name is the literal text of a pinned fingerprint does not satisfy it.
A peer outside the list is disconnected before any session state is touched. That ordering is the substance rather than a detail: an admitted connection displaces the active one, so a peer authorized too late would take the association down on its way to being refused. See D8.
Reporting connections and anomalies¶
Give the listener a connection_observer to receive a record of each connection
accepted, refused, displaced or ended, with its addresses, the certificate
identity it was admitted as and the reason it ended. Give the session an
anomaly_observer to receive a record of each frame or request it drops, with a
stable code and a severity. Connection and anomaly reports lists
the records and what produces each.
What it refuses¶
A function this outstation does not implement gets a response carrying
FUNC_NOT_SUPPORTED rather than silence, because a master that times out learns
nothing and retries. A control sent to an outstation with no outputs is answered
with OBJECT_UNKNOWN: the function is one it knows, and what is missing is
anything for it to act on.
A function can also be turned off: Session(disabled_functions=[...]) refuses each
one named exactly as it refuses a function it never implemented, which is the safer
configuration for an outstation with no use for it.
Something that is not a request at all is not answered: a fragment too short to hold a header, one marked as part of a longer message, a link frame whose length contradicts its function.
The exceptions are the five function codes the standard defines as taking no reply, which are dropped and logged rather than answered. See How DNP3 works and D9.
The one write this outstation honors is a master clearing the restart indication, at group 80 variation 1 index 7. An outstation that never cleared it would flag every response it ever sent.