Skip to content

Observers

Structured reports of connections and protocol anomalies. Connection and anomaly reports is the guide; this is the reference.

py1815.observers

Structured reports of connections and protocol anomalies, for the host application.

The outstation and the master log what happens to their connections, and the session logs a frame or a fragment it drops. A log line is for a person. A host application that keeps a record of every connection attempt, or that passes protocol anomalies to its own security monitoring, needs the same facts as fields. This module defines those records and the type of the callable that receives each one.

  • :class:ConnectionEvent is one thing that happened to a connection: accepted, refused, displaced or ended at the outstation's listener, and connected, failed or lost at the master. :class:~py1815.server.OutstationServer and :class:~py1815.master.api.Outstation take a connection_observer.
  • :class:ProtocolAnomaly is one frame, fragment or exchange the session dropped or gave up on, with a stable :class:AnomalyCode and a :class:AnomalySeverity. :class:~py1815.session.Session takes an anomaly_observer.

Each record is a frozen dataclass of plain values, so it can be logged, queued or converted to JSON with :func:dataclasses.asdict. The log lines are written as before; an observer is an addition to them, not a replacement.

An observer is called on the event loop, or for a session on whatever thread called it, and must return quickly. An observer that raises does not affect the connection or the session: the first failure of an observer is logged with its traceback, and later failures of the same observer are logged at DEBUG.

Copyright 2026 DER Security Corp. Licensed under the Apache License, Version 2.0.

ConnectionRole

Bases: StrEnum

Which end of the association reported a connection event.

Source code in src/py1815/observers.py
class ConnectionRole(StrEnum):
    """Which end of the association reported a connection event."""

    #: The outstation's listener, which accepts connections.
    OUTSTATION = "outstation"
    #: The master, which makes connections.
    MASTER = "master"

ConnectionDirection

Bases: StrEnum

Which end opened the connection, seen from the end that reports it.

Source code in src/py1815/observers.py
class ConnectionDirection(StrEnum):
    """Which end opened the connection, seen from the end that reports it."""

    #: The remote end opened it. Every connection the outstation reports.
    INBOUND = "inbound"
    #: The reporting end opened it. Every connection the master reports.
    OUTBOUND = "outbound"

ConnectionTransport

Bases: StrEnum

What the connection is carried over.

Source code in src/py1815/observers.py
class ConnectionTransport(StrEnum):
    """What the connection is carried over."""

    TCP = "tcp"
    TLS = "tls"

ConnectionOutcome

Bases: StrEnum

What happened to a connection.

Source code in src/py1815/observers.py
class ConnectionOutcome(StrEnum):
    """What happened to a connection."""

    #: The outstation admitted the connection as the master's.
    ACCEPTED = "accepted"
    #: The outstation closed the connection without admitting it.
    REFUSED = "refused"
    #: The outstation closed an admitted connection because a newer one was
    #: admitted in its place.
    DISPLACED = "displaced"
    #: An admitted connection ended: closed by the peer, reset, timed out,
    #: failed, closed by the local application, or ended by the listener
    #: stopping.
    ENDED = "ended"
    #: The master made the connection.
    CONNECTED = "connected"
    #: The master could not make the connection.
    FAILED = "failed"
    #: The master lost a connection it had made.
    LOST = "lost"

Endpoint dataclass

An address and port at one end of a connection.

Source code in src/py1815/observers.py
@dataclass(frozen=True)
class Endpoint:
    """An address and port at one end of a connection."""

    host: str
    port: int

    @classmethod
    def of(cls, name: Any) -> Endpoint | None:
        """Return the endpoint of a socket name, or None when the name holds none.

        ``name`` is what ``getpeername`` or ``getsockname`` returns: a tuple
        whose first two items are the address and the port.
        """
        if isinstance(name, tuple) and len(name) >= 2:
            try:
                return cls(str(name[0]), int(name[1]))
            except (TypeError, ValueError):
                return None
        return None

    def __str__(self) -> str:
        host = f"[{self.host}]" if ":" in self.host else self.host
        return f"{host}:{self.port}"
of classmethod
of(name: Any) -> Endpoint | None

Return the endpoint of a socket name, or None when the name holds none.

name is what getpeername or getsockname returns: a tuple whose first two items are the address and the port.

Source code in src/py1815/observers.py
@classmethod
def of(cls, name: Any) -> Endpoint | None:
    """Return the endpoint of a socket name, or None when the name holds none.

    ``name`` is what ``getpeername`` or ``getsockname`` returns: a tuple
    whose first two items are the address and the port.
    """
    if isinstance(name, tuple) and len(name) >= 2:
        try:
            return cls(str(name[0]), int(name[1]))
        except (TypeError, ValueError):
            return None
    return None

ConnectionEvent dataclass

One thing that happened to one connection.

Attributes:

Name Type Description
role ConnectionRole

The end that reports the event.

direction ConnectionDirection

Which end opened the connection.

outcome ConnectionOutcome

What happened.

transport ConnectionTransport

TCP, or TLS over TCP.

remote Endpoint | None

The other end. For a master's failed attempt, the host and port it tried.

local Endpoint | None

This end, or None when no socket was made.

peer_identity str | None

For a TLS connection, who the peer proved to be: at the outstation, the allow-list entry the master's certificate matched; at the master, the name the outstation's certificate was checked against, or the certificate's sha256: fingerprint when the context checks no name. None otherwise.

reason str

Why, in a few words, for every outcome except ACCEPTED and CONNECTED. It holds no secret.

name str | None

At the master, the name the outstation was added under. None at the outstation.

timestamp float

When the event was reported, in seconds since the Unix epoch.

Source code in src/py1815/observers.py
@dataclass(frozen=True)
class ConnectionEvent:
    """One thing that happened to one connection.

    Attributes:
        role: The end that reports the event.
        direction: Which end opened the connection.
        outcome: What happened.
        transport: TCP, or TLS over TCP.
        remote: The other end. For a master's failed attempt, the host and
            port it tried.
        local: This end, or None when no socket was made.
        peer_identity: For a TLS connection, who the peer proved to be: at the
            outstation, the allow-list entry the master's certificate matched;
            at the master, the name the outstation's certificate was checked
            against, or the certificate's ``sha256:`` fingerprint when the
            context checks no name. None otherwise.
        reason: Why, in a few words, for every outcome except ``ACCEPTED`` and
            ``CONNECTED``. It holds no secret.
        name: At the master, the name the outstation was added under. None at
            the outstation.
        timestamp: When the event was reported, in seconds since the Unix
            epoch.
    """

    role: ConnectionRole
    direction: ConnectionDirection
    outcome: ConnectionOutcome
    transport: ConnectionTransport
    remote: Endpoint | None
    local: Endpoint | None = None
    peer_identity: str | None = None
    reason: str = ""
    name: str | None = None
    timestamp: float = field(default_factory=time.time)

AnomalyCode

Bases: StrEnum

What the session dropped or gave up on. The values are stable.

Source code in src/py1815/observers.py
class AnomalyCode(StrEnum):
    """What the session dropped or gave up on. The values are stable."""

    #: A frame from a master address other than the one this session serves.
    UNEXPECTED_MASTER_ADDRESS = "unexpected_master_address"
    #: A frame from an address no master can have: a reserved or broadcast
    #: address, or this outstation's own.
    INVALID_SOURCE_ADDRESS = "invalid_source_address"
    #: A frame whose frame count valid bit is set on a function that does not
    #: count frames, or clear on one that does.
    INVALID_FRAME_COUNT_VALID = "invalid_frame_count_valid"
    #: A user data frame with no data, or another link function carrying data.
    LINK_PAYLOAD_MISMATCH = "link_payload_mismatch"
    #: A transport segment out of sequence, too large, or otherwise refused.
    TRANSPORT_ERROR = "transport_error"
    #: The same, in a broadcast.
    BROADCAST_TRANSPORT_ERROR = "broadcast_transport_error"
    #: A broadcast write whose objects could not be read.
    MALFORMED_BROADCAST_WRITE = "malformed_broadcast_write"
    #: A broadcast request the outstation received and did not carry out.
    BROADCAST_NOT_ACTED_ON = "broadcast_not_acted_on"
    #: A fragment that cannot be a request: too short, not a whole message, or
    #: marked as unsolicited.
    NOT_A_REQUEST = "not_a_request"
    #: A request whose object headers could not be read.
    MALFORMED_REQUEST = "malformed_request"
    #: An unsolicited response not confirmed after every retry allowed.
    UNSOLICITED_NOT_CONFIRMED = "unsolicited_not_confirmed"

AnomalySeverity

Bases: StrEnum

How much an anomaly says about the peer, from least to most.

Source code in src/py1815/observers.py
class AnomalySeverity(StrEnum):
    """How much an anomaly says about the peer, from least to most."""

    #: Expected in normal operation, and recorded for completeness.
    INFO = "info"
    #: Explained by a lossy link or a slow master as easily as by misbehavior.
    LOW = "low"
    #: A malformed frame or request: a defective or misconfigured peer, or one
    #: probing the outstation.
    MEDIUM = "medium"
    #: Traffic from an address that should not be speaking on this association.
    HIGH = "high"

ProtocolAnomaly dataclass

One frame, fragment or exchange the session dropped or gave up on.

Attributes:

Name Type Description
code AnomalyCode

What happened.

severity AnomalySeverity

How much it says about the peer. Always SEVERITY[code].

message str

The same text the session logs, without the dnp3: prefix.

source int | None

The link address the frame came from, or None when the anomaly concerns no single frame.

destination int | None

The link address the frame was sent to, or None.

peer Endpoint | None

The connection the octets arrived on, or the sender of a broadcast datagram, when the session was told. None for a session driven without a listener.

timestamp float

When the anomaly was reported, in seconds since the Unix epoch.

Source code in src/py1815/observers.py
@dataclass(frozen=True)
class ProtocolAnomaly:
    """One frame, fragment or exchange the session dropped or gave up on.

    Attributes:
        code: What happened.
        severity: How much it says about the peer. Always ``SEVERITY[code]``.
        message: The same text the session logs, without the ``dnp3:`` prefix.
        source: The link address the frame came from, or None when the anomaly
            concerns no single frame.
        destination: The link address the frame was sent to, or None.
        peer: The connection the octets arrived on, or the sender of a
            broadcast datagram, when the session was told. None for a session
            driven without a listener.
        timestamp: When the anomaly was reported, in seconds since the Unix
            epoch.
    """

    code: AnomalyCode
    severity: AnomalySeverity
    message: str
    source: int | None = None
    destination: int | None = None
    peer: Endpoint | None = None
    timestamp: float = field(default_factory=time.time)

ObserverGuard

Call an observer and keep its exceptions from reaching the caller.

The first exception of an observer is logged with its traceback. Later exceptions of the same observer are logged at DEBUG, so an observer that fails on every record does not fill the log. A different observer starts over.

Source code in src/py1815/observers.py
class ObserverGuard:
    """Call an observer and keep its exceptions from reaching the caller.

    The first exception of an observer is logged with its traceback. Later
    exceptions of the same observer are logged at DEBUG, so an observer that
    fails on every record does not fill the log. A different observer starts
    over.
    """

    def __init__(self, what: str) -> None:
        """Make a guard for the observer named ``what`` in log records."""
        self._what = what
        self._failed: object | None = None

    def deliver(self, observer: Callable[[_Record], None] | None, record: _Record) -> None:
        """Call ``observer`` with ``record``, unless there is no observer."""
        if observer is None:
            return
        try:
            observer(record)
        except Exception:  # pylint: disable=broad-exception-caught
            # The observer belongs to the host application. Its failure says
            # nothing about the connection or the session, which go on.
            if self._failed is observer:
                logger.debug("dnp3: the %s failed again", self._what, exc_info=True)
            else:
                self._failed = observer
                logger.exception(
                    "dnp3: the %s failed; further failures are logged at DEBUG", self._what
                )
deliver
deliver(
    observer: Callable[[_Record], None] | None,
    record: _Record,
) -> None

Call observer with record, unless there is no observer.

Source code in src/py1815/observers.py
def deliver(self, observer: Callable[[_Record], None] | None, record: _Record) -> None:
    """Call ``observer`` with ``record``, unless there is no observer."""
    if observer is None:
        return
    try:
        observer(record)
    except Exception:  # pylint: disable=broad-exception-caught
        # The observer belongs to the host application. Its failure says
        # nothing about the connection or the session, which go on.
        if self._failed is observer:
            logger.debug("dnp3: the %s failed again", self._what, exc_info=True)
        else:
            self._failed = observer
            logger.exception(
                "dnp3: the %s failed; further failures are logged at DEBUG", self._what
            )