Skip to main content

A pure Python DNP3 protocol driver for SCADA communication

Project description

nfm-dnp3 (dnp3py)

A pure Python implementation of the DNP3 (Distributed Network Protocol 3) protocol for SCADA communications over TCP/IP. Install from PyPI as nfm-dnp3; import in Python as dnp3py.

Overview

This driver implements the DNP3 protocol stack to communicate with DNP3 outstations (slaves) as a master station. It supports reading data points, controlling outputs, and handling events.

Features

  • Full Protocol Stack: Implements Data Link, Transport, and Application layers
  • TCP/IP Communication: Connect to DNP3 devices over IP networks
  • Data Point Support:
    • Binary Inputs (Group 1, 2)
    • Binary Outputs (Group 10, 12)
    • Analog Inputs (Group 30, 32)
    • Analog Outputs (Group 40, 41)
    • Counters (Group 20, 22)
  • Control Operations:
    • Direct Operate
    • Select-Before-Operate (SBO)
    • Pulse control
  • Class-Based Polling: Support for Class 0, 1, 2, 3 data
  • CRC-16 Error Detection: Compliant with DNP3 CRC polynomial
  • Thread-Safe: DNP3Master supports concurrent use (open/close/requests protected by a lock)

Requirements

  • Python 3.9+

Installation

# Clone the repository
git clone https://github.com/fxodell/dnp3py.git
cd dnp3py

# Install from PyPI
pip install nfm-dnp3

# Or install in development mode from source
pip install -e .

After installation, use from dnp3py import ... from any directory.

Quick Start

from dnp3py import DNP3Master, DNP3Config

# Configure connection
config = DNP3Config(
    host="192.168.1.100",     # Outstation IP
    port=20000,               # DNP3 port (default: 20000)
    master_address=1,         # Master address
    outstation_address=10,    # Outstation address
)

# Create master instance
master = DNP3Master(config)

# Connect and read data
with master.connect():
    # Integrity poll - read all data
    result = master.integrity_poll()

    if result.success:
        print(f"Binary Inputs: {result.binary_inputs}")
        print(f"Analog Inputs: {result.analog_inputs}")
        print(f"Counters: {result.counters}")

    # Read specific points
    binary_inputs = master.read_binary_inputs(0, 9)
    analog_inputs = master.read_analog_inputs(0, 4)

    # Control a binary output
    master.direct_operate_binary(0, value=True)  # Turn ON
    master.direct_operate_binary(0, value=False)  # Turn OFF

    # Control an analog output
    master.direct_operate_analog(0, value=50.0)

Run the interactive examples (requires nfm-dnp3 installed):

python examples/basic_usage.py

Architecture

dnp3py/
├── __init__.py           # Package exports
├── setup.py              # Package setup (pip install -e .)
├── test_connection.py    # Quick connection test (edit host/port/address)
├── core/
│   ├── master.py         # DNP3Master (main interface; thread-safe, connect() context manager)
│   ├── config.py         # Configuration and protocol constants
│   └── exceptions.py     # Custom exceptions
├── layers/
│   ├── datalink.py       # Data Link Layer (frames, CRC)
│   ├── transport.py      # Transport Layer (segmentation)
│   └── application.py    # Application Layer (requests/responses)
├── objects/
│   ├── binary.py         # Binary I/O objects
│   ├── analog.py         # Analog I/O objects
│   ├── counter.py        # Counter objects
│   └── groups.py         # Object group definitions
├── utils/
│   ├── crc.py            # CRC-16 calculation
│   └── logging.py        # Logging utilities
├── examples/
│   ├── basic_usage.py    # Basic usage examples
│   └── async_example.py   # Async/threading example
├── docs/                 # Documentation
└── tests/                # Unit tests

Protocol Layers

Data Link Layer

  • FT3 frame format with 0x0564 start bytes
  • Source and destination addressing (0-65519)
  • CRC-16 error checking every 16 bytes
  • Maximum frame size: 292 bytes (250 bytes user data)

Transport Layer

  • Message segmentation/reassembly
  • 1-byte transport header with sequence numbering
  • FIR (First) and FIN (Final) segment flags
  • Maximum segment payload: 249 bytes

Application Layer

  • Request/Response message formatting
  • Function codes (READ, WRITE, SELECT, OPERATE, etc.)
  • Object headers with qualifiers
  • Internal Indications (IIN) handling

Configuration Options

DNP3Config validates and normalizes values when you create a DNP3Master (e.g. host trimmed, port/addresses coerced to int). Invalid settings raise ValueError or TypeError.

config = DNP3Config(
    # Network
    host="192.168.1.100",
    port=20000,

    # Addressing
    master_address=1,
    outstation_address=10,

    # Timeouts (seconds)
    response_timeout=5.0,
    connection_timeout=10.0,
    select_timeout=10.0,   # SBO: time between SELECT and OPERATE

    # Retries
    max_retries=3,
    retry_delay=1.0,

    # Data Link
    confirm_required=True,
    max_frame_size=250,

    # Logging
    log_level="INFO",
    log_raw_frames=False,
)

Control Operations

Direct Operate

Immediately executes the control command:

# Turn output ON
master.direct_operate_binary(index=0, value=True)

# Turn output OFF
master.direct_operate_binary(index=0, value=False)

# Set analog setpoint
master.direct_operate_analog(index=0, value=50.0)

Select-Before-Operate (SBO)

Two-step control for safety-critical operations:

# SELECT then OPERATE
master.select_operate_binary(index=0, value=True)

Pulse Control

Generate timed pulses on outputs:

# Pulse ON for 500ms, 3 times
master.pulse_binary(
    index=0,
    on_time=500,    # milliseconds
    off_time=500,   # milliseconds
    count=3,
    pulse_on=True,
)

Exception Handling

All DNP3 exceptions inherit from DNP3Error. Catch it for any driver error, or use specific types for context (e.g. host/port on DNP3CommunicationError, timeout_seconds on DNP3TimeoutError).

from dnp3py import (
    DNP3Error,
    DNP3CommunicationError,
    DNP3TimeoutError,
    DNP3ProtocolError,
    DNP3CRCError,
)

try:
    with master.connect():
        result = master.integrity_poll()
except DNP3TimeoutError as e:
    print(f"Timeout after {e.timeout_seconds}s")
except DNP3CommunicationError as e:
    print(f"Connection failed: {e} (host={e.host}, port={e.port})")
except DNP3CRCError:
    print("CRC validation failed")
except DNP3Error as e:
    print(f"DNP3 error: {e}")

For frame, object, or control-specific errors, use from dnp3py.core import DNP3FrameError, DNP3ObjectError, DNP3ControlError.

Running Tests

From the project root (with nfm-dnp3 or pip install -e .):

pytest tests/ -v

To quickly test a live outstation, edit test_connection.py with your host, port, and outstation address, then run:

python test_connection.py

Local check (tests + lint + security, same as CI):

pytest tests/ -q && ruff check . && ruff format --check . && bandit -r . -c pyproject.toml -x tests,.venv,venv

References

Development

  • Setup: setup.py uses package_dir={"dnp3py": "."} (repo root is the package); find_packages() excludes tests so the test suite is not installed. Version is read from __init__.py as the single source of truth. Long description is taken from README.md. Install dev dependencies with pip install -e ".[dev]" for pytest, pytest-cov, bandit, ruff, and pyright.
  • Commands: Run tests: pytest tests/ -v. Lint: ruff check . and ruff format --check .. Security: bandit -r . -c pyproject.toml. Type check: pyright (optional; requires reportMissingImports = false in pyproject until run from an env where dnp3py is installed).
  • Git: .gitignore excludes bytecode (__pycache__/, *.pyc), build artifacts (build/, dist/, *.egg-info/), virtual envs (.venv/, venv/), IDE/editor dirs (.idea/, .vscode/), test/cache (.pytest_cache/, .coverage, htmlcov/), tool caches (.mypy_cache/, .ruff_cache/), *.log, and .claude/settings.local.json; OS cruft (.DS_Store) is ignored. After pip install -e ., the dnp3py.egg-info/ directory appears in the repo root; it is generated metadata and is correctly ignored—do not commit it.
  • Package layout: Install with pip install -e . from the repo root; dnp3py is the top-level package. The root __init__.py exports DNP3Master, DNP3Config, the exception classes (DNP3Error, DNP3CommunicationError, DNP3TimeoutError, DNP3ProtocolError, DNP3CRCError), and __version__ via __all__. Subpackages use relative or absolute imports; each __init__.py exposes a public API via __all__.
  • Core package: core/__init__.py re-exports DNP3Master, DNP3Config, PollResult (return type of integrity_poll() and read_class()), and all eight DNP3 exception classes. The top-level dnp3py package exports only the five most common exceptions; use from dnp3py.core import PollResult, DNP3FrameError, etc., when needed.
  • Master: core/master.py implements DNP3Master; it coordinates Data Link, Transport, and Application layers and is thread-safe (open/close and request/response use a single lock). Use the connect() context manager or open()/close() for connection life cycle. DNP3CommunicationError is raised with host and port set from config when send/receive or connection fails, for easier debugging.
  • Config: core/config.py defines DNP3Config and protocol enums (LinkLayerFunction, AppLayerFunction, QualifierCode, ControlCode, ControlStatus, IINFlags). DNP3Config.validate() normalizes and validates all fields: host (non-empty string), port (1-65535), master/outstation addresses (0-65519), timeouts and retry_delay (coerced to float, positive or ≥0), max_frame_size (1-250), max_apdu_size (1-65536), poll intervals (≥0), and log_level (DEBUG/INFO/WARNING/ERROR/CRITICAL). IINFlags.from_bytes(iin1, iin2) validates that iin1/iin2 are coercible to int. Called automatically when creating a DNP3Master.
  • Objects: Binary, analog, and counter modules validate input length and value ranges in from_bytes/to_bytes and in parse_* (e.g. count/start_index ≥ 0); invalid data raises clear ValueErrors or TypeError. Analog (objects/analog.py) additionally validates data type (bytes/bytearray), index ≥ 0, and variation in valid range (1–6 for AnalogInput, 1–4 for AnalogOutput/AnalogOutputCommand) in from_bytes/to_bytes/create() and parse_analog_inputs/parse_analog_outputs.
  • Objects package: objects/__init__.py re-exports data types (BinaryInput, AnalogInput, Counter, etc.), ObjectGroup, ObjectVariation, get_object_size, and get_group_name; parse functions (parse_binary_inputs, parse_analog_inputs, etc.) are in the binary, analog, and counter submodules.
  • Groups: objects/groups.py defines ObjectGroup, ObjectVariation, OBJECT_SIZES, get_object_size(), and get_group_name() for protocol and parsing use.
  • Layers: layers/__init__.py re-exports DataLinkLayer, TransportLayer, and ApplicationLayer; frame, segment, and request/response types live in the datalink, transport, and application submodules. Data Link (layers/datalink.py) validates addresses in build_frame, build_request_link_status, and build_reset_link; calculate_frame_size validates the length byte; frame parsing checks CRCs and length. Transport (layers/transport.py) validates APDU length (≤ MAX_MESSAGE_SIZE) and max_payload (1..MAX_SEGMENT_PAYLOAD) in segment(); TransportSegment.from_bytes rejects oversized segments; parse_header() validates header byte 0-255; reassembly enforces sequence, size limit, and timeout. Application (layers/application.py) validates ObjectHeader group/variation/qualifier (0-255) and range/count per qualifier in to_bytes(); ObjectHeader.from_bytes() validates offset and range; ApplicationRequest validates sequence and function; build_confirm() and build_read_request() validate sequence (0-15) and group/variation/start/stop.
  • Utils: utils/__init__.py re-exports CRC16DNP3, calculate_frame_crc, setup_logging, get_logger, log_frame, and log_parsed_frame. utils/crc.py provides DNP3 CRC-16 (polynomial 0x3D65, reflected 0xA6BC, final XOR 0xFFFF); CRC16DNP3.calculate() and calculate_frame_crc() validate bytes/bytearray; verify_bytes() validates 2-byte CRC. utils/logging.py validates level (DEBUG/INFO/WARNING/ERROR/CRITICAL), uses UTF-8 for file output, sets propagate=False; log_frame() and log_parsed_frame() validate frame/frame_info types.
  • Exceptions: core/exceptions.py defines the hierarchy: DNP3Error (base); DNP3CommunicationError (host, port), DNP3TimeoutError (timeout_seconds), DNP3ProtocolError (function_code, iin), DNP3CRCError (expected_crc, actual_crc), DNP3FrameError, DNP3ObjectError (group, variation), DNP3ControlError (status_code). The top-level dnp3py package exports the first five; DNP3FrameError, DNP3ObjectError, and DNP3ControlError are available from dnp3py.core. All use Optional for context attributes.
  • Publishing to PyPI: This project is published as nfm-dnp3 on PyPI (Trusted Publisher from GitHub Actions). (1) Bump version in __init__.py, (2) tag and push (e.g. git tag v1.0.1 && git push origin v1.0.1); the publish workflow uploads to PyPI. For manual upload: pip install build twine, python -m build, then twine upload dist/* with PyPI token.

Security

  • No unsafe patterns: The codebase does not use eval, exec, __import__ with untrusted input, or pickle.loads on network data. Protocol parsing is binary-only; no code execution from DNP3 payloads.
  • Checks: Run bandit -r . -c pyproject.toml (excludes tests/) to scan for common issues. CI runs bandit on every push/PR.
  • Reporting: If you find a security concern, please report it privately (e.g. via the repository's security policy or maintainer contact) rather than in a public issue.

License

This implementation is provided as-is for educational and development purposes.

Disclaimer

This is a reference implementation. For production SCADA systems, consider evaluation of certified DNP3 stacks such as OpenDNP3.

Project details


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

nfm_dnp3-1.0.1.tar.gz (63.5 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

nfm_dnp3-1.0.1-py3-none-any.whl (56.7 kB view details)

Uploaded Python 3

File details

Details for the file nfm_dnp3-1.0.1.tar.gz.

File metadata

  • Download URL: nfm_dnp3-1.0.1.tar.gz
  • Upload date:
  • Size: 63.5 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/6.1.0 CPython/3.13.7

File hashes

Hashes for nfm_dnp3-1.0.1.tar.gz
Algorithm Hash digest
SHA256 9473240833e4547fb8dcbb3883db456b54b4c9cb578660c8d6700c82283ab6d7
MD5 5ee3d266196e8d4478a529d9de3ed937
BLAKE2b-256 e0c7f89bc06729c8930042abab8b87c54930aa4d706e013c364c01db3f7f22fc

See more details on using hashes here.

Provenance

The following attestation bundles were made for nfm_dnp3-1.0.1.tar.gz:

Publisher: publish.yml on fxodell/dnp3py

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

File details

Details for the file nfm_dnp3-1.0.1-py3-none-any.whl.

File metadata

  • Download URL: nfm_dnp3-1.0.1-py3-none-any.whl
  • Upload date:
  • Size: 56.7 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/6.1.0 CPython/3.13.7

File hashes

Hashes for nfm_dnp3-1.0.1-py3-none-any.whl
Algorithm Hash digest
SHA256 aafb2416e3ad52cc1812bfd6a6dc2ee7459a92a0c95da94b6da62c70a69c3cc0
MD5 932c03f9832e7f32e3e67171d0263078
BLAKE2b-256 30efd21a8c20a09b432b63479b37458118e1625ecfe5d755f4e49da1e4eb8587

See more details on using hashes here.

Provenance

The following attestation bundles were made for nfm_dnp3-1.0.1-py3-none-any.whl:

Publisher: publish.yml on fxodell/dnp3py

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page