Metadata-Version: 2.3
Name: dnp3-outstation
Version: 0.2.0
Summary: Pure-Python DNP3 outstation (IEEE 1815-2012, Level 1 subset) -- aarch64-compatible
Author: joenarvaez
Author-email: joenarvaez <joe@arketyped.net>
License: MIT
Requires-Python: >=3.11
Description-Content-Type: text/markdown

# dnp3-outstation 🐍

Pure-Python DNP3 outstation (IEEE 1815-2012, Level 1 subset).

aarch64-compatible drop-in replacement for the outstation half of
[`dnp3-python`](https://pypi.org/project/dnp3-python/), which ships
x86_64-only wheels.

## Scope (and what's deliberately not in)

✅ One TCP outstation per process, asyncio-native
✅ Group 30 / variation 5 analog inputs (32-bit float with flag byte)
✅ READ requests with qualifier 0x06 (class-0 / integrity poll) and qualifier 0x00 (8-bit range)
✅ Responses encoded as one g30v5 qualifier-0x00 header per contiguous index run (rust dnp3 + opendnp3 accept this)
✅ Datalink REQUEST_LINK_STATUS → LINK_STATUS, RESET_LINK_STATES → ACK
✅ WRITE (FC=0x02) ACKed -- master can clear IIN1.RESTART
✅ IIN1.RESTART on cold start, self-clears after first response
✅ Interop verified against the rust `dnp3` v1.6 master crate (same one used by `ems-industrial-gateway`)

❌ Master mode
❌ Secure authentication (SAv5/SAv6)
❌ File transfer, time sync, control output
❌ Event classes 1/2/3, change-driven reporting
❌ Function codes beyond READ (0x01) and WRITE (0x02)

Anything missing comes from the same place: this lib exists to publish a
small, fixed set of analog values to a polling master. That's it.

## Install

```bash
pip install dnp3-outstation
```

Python 3.11+, pure-Python — works on any platform Python runs on (Raspberry
Pi 5 aarch64 included).

## Usage

```python
import asyncio
from dnp3_outstation import AsyncOutstation

async def main():
    outstation = AsyncOutstation(
        host="0.0.0.0",
        port=20000,
        master_addr=1,
        outstation_addr=1024,
    )
    outstation.set_analog(idx=0, value=1_000_000.0)
    outstation.set_analog(idx=1, value=2_000_000.0)
    outstation.set_analog(idx=10, value=250.0)

    await outstation.start()
    try:
        while True:
            # update values on whatever cadence makes sense
            outstation.set_analog(idx=10, value=read_my_sensor())
            await asyncio.sleep(2)
    finally:
        await outstation.shutdown()

asyncio.run(main())
```

`set_analog(idx, value, flag=0x01)` updates the in-memory point database.
The next master poll returns whatever's there. Flag byte defaults to
`0x01` (ONLINE) per the DNP3 analog-input flag definition.

## Architecture

Layered to mirror IEEE 1815's stack so each layer tests independently:

```
   AsyncOutstation     asyncio TCP server, lifecycle
       |
   Outstation          state + orchestration (sync)
       |
   application.py      AC/FC/IIN, g30v5 encoding, READ parsing
       |
   transport.py        FIN/FIR/SEQ segment header
       |
   datalink.py         SYNC + LEN + CTRL + addresses + CRC blocks
       |
   crc.py              CRC-16/DNP per spec section 11.7
```

Every layer has colocated unit tests under `src/dnp3_outstation/test_*.py`.

## License

MIT.
