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:
DNP3Mastersupports 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
- IEEE Std 1815 - DNP3 Standard
- DNP Users Group
- DNP3 Protocol Overview
Development
- Setup:
setup.pyusespackage_dir={"dnp3py": "."}(repo root is the package);find_packages()excludestestsso the test suite is not installed. Version is read from__init__.pyas the single source of truth. Long description is taken fromREADME.md. Install dev dependencies withpip install -e ".[dev]"for pytest, pytest-cov, bandit, ruff, and pyright. - Commands: Run tests:
pytest tests/ -v. Lint:ruff check .andruff format --check .. Security:bandit -r . -c pyproject.toml. Type check:pyright(optional; requiresreportMissingImports = falsein pyproject until run from an env wherednp3pyis installed). - Git:
.gitignoreexcludes 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. Afterpip install -e ., thednp3py.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;dnp3pyis the top-level package. The root__init__.pyexportsDNP3Master,DNP3Config, the exception classes (DNP3Error,DNP3CommunicationError,DNP3TimeoutError,DNP3ProtocolError,DNP3CRCError), and__version__via__all__. Subpackages use relative or absolute imports; each__init__.pyexposes a public API via__all__. - Core package:
core/__init__.pyre-exportsDNP3Master,DNP3Config,PollResult(return type ofintegrity_poll()andread_class()), and all eight DNP3 exception classes. The top-leveldnp3pypackage exports only the five most common exceptions; usefrom dnp3py.core import PollResult, DNP3FrameError, etc., when needed. - Master:
core/master.pyimplementsDNP3Master; it coordinates Data Link, Transport, and Application layers and is thread-safe (open/close and request/response use a single lock). Use theconnect()context manager oropen()/close()for connection life cycle.DNP3CommunicationErroris raised withhostandportset from config when send/receive or connection fails, for easier debugging. - Config:
core/config.pydefinesDNP3Configand 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 aDNP3Master. - Objects: Binary, analog, and counter modules validate input length and value ranges in
from_bytes/to_bytesand inparse_*(e.g.count/start_index≥ 0); invalid data raises clearValueErrors orTypeError. Analog (objects/analog.py) additionally validatesdatatype (bytes/bytearray),index≥ 0, andvariationin valid range (1–6 for AnalogInput, 1–4 for AnalogOutput/AnalogOutputCommand) infrom_bytes/to_bytes/create()andparse_analog_inputs/parse_analog_outputs. - Objects package:
objects/__init__.pyre-exports data types (BinaryInput, AnalogInput, Counter, etc.),ObjectGroup,ObjectVariation,get_object_size, andget_group_name; parse functions (parse_binary_inputs,parse_analog_inputs, etc.) are in the binary, analog, and counter submodules. - Groups:
objects/groups.pydefinesObjectGroup,ObjectVariation,OBJECT_SIZES,get_object_size(), andget_group_name()for protocol and parsing use. - Layers:
layers/__init__.pyre-exportsDataLinkLayer,TransportLayer, andApplicationLayer; frame, segment, and request/response types live in the datalink, transport, and application submodules. Data Link (layers/datalink.py) validates addresses inbuild_frame,build_request_link_status, andbuild_reset_link;calculate_frame_sizevalidates the length byte; frame parsing checks CRCs and length. Transport (layers/transport.py) validates APDU length (≤ MAX_MESSAGE_SIZE) andmax_payload(1..MAX_SEGMENT_PAYLOAD) insegment();TransportSegment.from_bytesrejects oversized segments;parse_header()validates header byte 0-255; reassembly enforces sequence, size limit, and timeout. Application (layers/application.py) validatesObjectHeadergroup/variation/qualifier (0-255) and range/count per qualifier into_bytes();ObjectHeader.from_bytes()validates offset and range;ApplicationRequestvalidates sequence and function;build_confirm()andbuild_read_request()validate sequence (0-15) and group/variation/start/stop. - Utils:
utils/__init__.pyre-exportsCRC16DNP3,calculate_frame_crc,setup_logging,get_logger,log_frame, andlog_parsed_frame.utils/crc.pyprovides DNP3 CRC-16 (polynomial 0x3D65, reflected 0xA6BC, final XOR 0xFFFF);CRC16DNP3.calculate()andcalculate_frame_crc()validate bytes/bytearray;verify_bytes()validates 2-byte CRC.utils/logging.pyvalidates level (DEBUG/INFO/WARNING/ERROR/CRITICAL), uses UTF-8 for file output, setspropagate=False;log_frame()andlog_parsed_frame()validate frame/frame_info types. - Exceptions:
core/exceptions.pydefines 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-leveldnp3pypackage exports the first five;DNP3FrameError,DNP3ObjectError, andDNP3ControlErrorare available fromdnp3py.core. All useOptionalfor 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, thentwine upload dist/*with PyPI token.
Security
- No unsafe patterns: The codebase does not use
eval,exec,__import__with untrusted input, orpickle.loadson network data. Protocol parsing is binary-only; no code execution from DNP3 payloads. - Checks: Run
bandit -r . -c pyproject.toml(excludestests/) 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
Release history Release notifications | RSS feed
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
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
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
9473240833e4547fb8dcbb3883db456b54b4c9cb578660c8d6700c82283ab6d7
|
|
| MD5 |
5ee3d266196e8d4478a529d9de3ed937
|
|
| BLAKE2b-256 |
e0c7f89bc06729c8930042abab8b87c54930aa4d706e013c364c01db3f7f22fc
|
Provenance
The following attestation bundles were made for nfm_dnp3-1.0.1.tar.gz:
Publisher:
publish.yml on fxodell/dnp3py
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
nfm_dnp3-1.0.1.tar.gz -
Subject digest:
9473240833e4547fb8dcbb3883db456b54b4c9cb578660c8d6700c82283ab6d7 - Sigstore transparency entry: 924589296
- Sigstore integration time:
-
Permalink:
fxodell/dnp3py@8d1dc7f50d319a8dbb90e1c3091754d6ab02d1a6 -
Branch / Tag:
refs/tags/v1.0.1 - Owner: https://github.com/fxodell
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
publish.yml@8d1dc7f50d319a8dbb90e1c3091754d6ab02d1a6 -
Trigger Event:
push
-
Statement type:
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
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
aafb2416e3ad52cc1812bfd6a6dc2ee7459a92a0c95da94b6da62c70a69c3cc0
|
|
| MD5 |
932c03f9832e7f32e3e67171d0263078
|
|
| BLAKE2b-256 |
30efd21a8c20a09b432b63479b37458118e1625ecfe5d755f4e49da1e4eb8587
|
Provenance
The following attestation bundles were made for nfm_dnp3-1.0.1-py3-none-any.whl:
Publisher:
publish.yml on fxodell/dnp3py
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
nfm_dnp3-1.0.1-py3-none-any.whl -
Subject digest:
aafb2416e3ad52cc1812bfd6a6dc2ee7459a92a0c95da94b6da62c70a69c3cc0 - Sigstore transparency entry: 924589304
- Sigstore integration time:
-
Permalink:
fxodell/dnp3py@8d1dc7f50d319a8dbb90e1c3091754d6ab02d1a6 -
Branch / Tag:
refs/tags/v1.0.1 - Owner: https://github.com/fxodell
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
publish.yml@8d1dc7f50d319a8dbb90e1c3091754d6ab02d1a6 -
Trigger Event:
push
-
Statement type: