Skip to content

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_timeout until the master confirms it;
  • answers a master enabling or disabling classes 1, 2 and 3 with ENABLE_UNSOLICITED and DISABLE_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_resume seconds (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:

session = Session(Provider(), outstation_address=1024, master_address=1)

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:

session = Session(Provider(), outstation_address=1024, master_address=None)

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.