Pure-Zig DNP3 (IEEE 1815-2012): the Data Link Layer, the Transport Function, the Application Layer and the object library as pure codecs — plus a complete outstation built on top of them. The master role is the codecs (build request / parse response); the outstation role is a real stateful responder with IIN handling, event buffering, select-before-operate and fragmentation. Feeds SCADA/utility fleet-simulation now; real RTU monitoring/control is a later consumer.
- No mature pure-Zig DNP3 library exists.
- Platform: any — every codec is a pure function over byte slices, no allocation, no I/O. Wiring to a real transport (TCP or serial) is left to the caller.
- Model after: IEEE 1815-2012; structure/behavior cross-referenced against opendnp3 (Apache-2.0) and public DNP3 primers — no source consulted or copied.
Implemented:
link(§9) — the 0x0564 fixed frame: start bytes, length, the control octet (DIR/PRM/FCB/FCV + a 4-bit function code), 16-bit dest/source link addresses, and the DNP3 CRC-16 (poly 0x3D65) over the 8-byte header block and each ≤16-byte user-data block.transport(§8) — the single transport octet (FIN/FIR + a 6-bit sequence number);Segmentersplits an application fragment into link-sized chunks,Reassemblerputs them back together, enforcing the FIR/sequence rules.application(§4/§5) — the application control octet (FIR/FIN/CON/UNS- a 4-bit sequence), the function-code byte (CONFIRM, READ, WRITE, SELECT, OPERATE, DIRECT_OPERATE, RESPONSE, UNSOLICITED_RESPONSE, …), and the 2-byte Internal Indications (IIN) bitfield carried in responses.
objects— the object-header framing (group/variation + qualifier + range: start-stop, all-values, count, count+index-prefix) and a core static object library: binary input (g1), binary output status + CROB (g10/g12), binary counter (g20), analog input (g30), analog output status- block (g40/g41), time-and-date (g50), and class-data read markers (g60).
records— a table-driven codec for the ~40 group/variation pairs an outstation actually has to emit. A variation is described as a layout (does it carry a flags octet, what width and type is the value, does a timestamp follow) rather than a hand-written struct: binary (g1/g2), double-bit binary (g3/g4), binary output status (g10/g11), counter (g20/g22), frozen counter (g21/g23), analog input (g30/g32) and analog output status (g40/g42), in the with-flags / without-flags / 16-bit / 32-bit / float / absolute-time / relative-time shapes, including the packed single-bit and packed double-bit forms.outstation— the responder.handle(request, now_ms, out) -> ?Replyis a pure function from one application fragment to one application fragment over a caller-owned point database;Sessionwraps it in the transport function and the data-link layer so a caller can feed whole link frames. No threads, no owned timers, no allocation; every deadline is driven by an injected clock. Specifically:- Function codes: READ (class 0/1/2/3 polls and specific group/variation reads), WRITE (clear the restart IIN, set time), SELECT / OPERATE / DIRECT_OPERATE / DIRECT_OPERATE_NO_ACK for CROB (g12) and analog output blocks (g41), COLD_RESTART, WARM_RESTART, DELAY_MEASURE, ENABLE / DISABLE_UNSOLICITED, IMMEDIATE_FREEZE (+ no-ack and freeze-clear), ASSIGN_CLASS, RECORD_CURRENT_TIME and CONFIRM. Anything else answers IIN2.FUNC_NOT_SUPPORTED.
- IIN: device restart (set at power-up, cleared only by an explicit WRITE of g80v1 index 7), class 1/2/3 events available, need-time, event buffer overflow, local control, device trouble, plus the per-request func-not-supported / object-unknown / parameter-error bits.
- Events: a bounded caller-owned ring, reported oldest-first with the
right event group and variation and an index prefix, retired only when
the master's CONFIRM arrives. A response carrying events always sets CON.
Overflow drops the oldest and latches IIN2.3 until the buffer drains.
confirmTimedOut()puts the in-flight events back on the queue. - Select-before-operate: the select is remembered with its arm time, its
exact object bytes and the peer that armed it; an OPERATE with the
wrong sequence number, different objects, or a different source address is
NO_SELECT, one that arrives afterselect_timeout_msisTIMEOUT, and neither executes. An OPERATE from the wrong peer also leaves the arm standing, so a foreign station can neither fire another station's select nor cancel it. - Fragmentation: a response too large for
max_tx_fragmentsplits with correct FIR/FIN and an incrementing application sequence number, each non-final fragment asking for a confirm;next()produces the following one. The transport layer below segments consistently. - Unsolicited responses:
unsolicited()builds the fragment (UNS + CON set, its own sequence counter, suppressed while one is unconfirmed or while the restart IIN is set). The retry timer is the caller's.
- Malformed/short/corrupt bytes never panic anywhere in
link/transport/application/objects/records/outstation— every decode path returns a typed error, and every hostile request fragment becomes a response with the right IIN bits set.
Secure Authentication — sa (SAv2 symmetric core, g120): AES Key Wrap
(RFC 3394, byte-exact against the published vectors), the SA MAC-algorithm
registry (HMAC-SHA-1/SHA-256 truncated, AES-GMAC, constant-time verify), the
g120 v1/v2/v3/v4/v5/v6/v7/v9 message codecs, the challenge-response MAC-input
construction, and session-key wrap/unwrap + CSQ/KSQ/expiry state helpers. The
SAv5/SAv6 asymmetric update-key change (g120 v8/v10–v15: RSA/DSA +
certificates) is out of scope — see sa.zig's doc comment and SPEC.md for
exactly what is implemented and validated against what.
Honest list of what this module does not do:
- Unsolicited retry policy. The fragment builder is here; the retry timer
and back-off are the caller's, driven through
confirmTimedOut(). - File transfer (g70), data sets (g85–g88), device attributes (g0), octet strings (g110/g111), time-and-interval (g50v4), analog deadbands (g34), time-synchronisation with delay compensation (g50v2/g51).
- Secure Authentication integration.
sa.zigis a complete SAv2 symmetric codec, but nothing inoutstation.zigwraps a fragment in g120 objects yet. - Data-link confirmed user data. The outstation answers RESET_LINK_STATES, TEST_LINK_STATES and REQUEST_LINK_STATUS, and accepts both confirmed and unconfirmed user data, but always sends its own frames as unconfirmed user data. The FCB/FCV toggle is decoded, not enforced.
- Actual transport I/O.
Sessionproduces and consumes byte slices; running it over TCP or a serial port (or over a simulated fleet) is the caller's job. - A stateful master session. The master role is still the pure codecs — no poll scheduler, no task queue, no response-timeout state machine.
- Relative-time event variations (g2v3, g4v3) are encodable by
records, but the outstation never chooses them: it emits absolute time.
const dnp3 = @import("dnp3");
// -- master: build a READ request for Analog Input (g30v1), points 0-3 --
var req_buf: [16]u8 = undefined;
const request = try dnp3.application.buildRequest(1, .read, .{
.group = 30,
.variation = 1,
.qualifier = .{ .prefix_code = .none, .range_code = .start_stop_1b },
.range = .{ .start_stop = .{ .start = 0, .stop = 3 } },
}, &req_buf);
// hand `request` to the transport function + link layer for the wire:
var wire_buf: [64]u8 = undefined;
const control = dnp3.link.Control{
.dir = true, .prm = true, .fcv_or_dfc = true,
.function = @intFromEnum(dnp3.link.PrimaryFunction.unconfirmed_user_data),
};
const frames = try dnp3.sendFragment(control, dest_addr, src_addr, request, &wire_buf);
// frames is ready to write to a socket/serial port.
// -- outstation: reassemble frames back into the request, then parse it --
var reasm_buf: [64]u8 = undefined;
var receiver = dnp3.FrameReceiver.init(&reasm_buf);
var scratch: [64]u8 = undefined;
const fragment = (try receiver.feedFrame(frames, &scratch)).?;
const req = try dnp3.application.decodeRequestHeader(fragment);
const obj = try dnp3.objects.decodeObjectHeader(req.rest);
// obj.header.group == 30, obj.header.range.objectCount() == 4var binaries = [_]dnp3.outstation.BinaryInput{.{ .class = .class1 }} ** 8;
var analogs = [_]dnp3.outstation.AnalogInput{.{ .class = .class2 }} ** 4;
var event_storage: [64]dnp3.outstation.Event = undefined;
var station = dnp3.outstation.Outstation.init(.{
.address = 10,
.master_address = 1,
.select_timeout_ms = 10_000,
}, .{
.binary_inputs = &binaries,
.analog_inputs = &analogs,
}, dnp3.outstation.EventBuffer.init(&event_storage));
// Drive the process image; the event is buffered with it.
station.update(.binary_input, 3, .{ .binary = true }, now_ms);
// Feed whole link frames, get whole link frames back.
var rx: [4096]u8 = undefined;
var scratch: [512]u8 = undefined;
var tx: [2048]u8 = undefined;
var session = dnp3.outstation.Session.init(&station, &rx, &scratch, &tx);
var out: [4096]u8 = undefined;
if (try session.feedFrame(frame_bytes, now_ms, &out)) |reply_frames| {
// write `reply_frames` to the socket
}
// Outstation-initiated traffic, framed by the same session (so the transport
// sequence number stays consistent with the solicited path):
if (try session.nextFrames(now_ms, &out)) |more| { /* next response fragment */ }
if (try session.unsolicitedFrames(now_ms, &out)) |uns| { /* unsolicited response */ }Session.feedFrame filters both data-link addresses. A frame whose
destination is neither config.address nor one of the three broadcast
addresses (0xFFFD-0xFFFF) is dropped; a broadcast is executed but never
answered, per IEEE 1815 §9.2.4.1.4. A frame whose source is not
config.master_address is dropped as well — broadcasts included, since a
broadcast from a station we do not talk to is still not ours. One Outstation
is one DNP3 association (one master, one outstation, one set of addresses), so
anything from another source belongs to somebody else's.
The source filter can be turned off with config.require_master_source = false
— the same escape hatch opendnp3 spells respondToAnySource and Step Function
I/O's Rust dnp3 spells respond_to_any_master, and, like theirs, it is off by
default. Turning it off does not unbind the select from its peer: an
armed SELECT records the source address it came from, and an OPERATE from a
different one is refused whatever this switch says. To serve several masters,
give each its own Outstation over a shared point database — that is the
association model, and it is what both reference stacks do.
Authorising the peer beyond its claimed link address is still the caller's job (or DNP3-SA's): a source address is an addressing field, not a credential.
Session frames all three directions the outstation can speak: replies to a
request (feedFrame), the continuation fragments of a multi-fragment response
(nextFrames) and unsolicited responses (unsolicitedFrames). All three share
Session.tx_seq, so a caller never has to reach past Session into
link/transport — doing that means duplicating the sequence bookkeeping,
which is exactly what confuses a master's reassembler. unsolicitedFrames
returns null whenever Outstation.unsolicited has nothing to send (feature
disabled, one already awaiting confirmation, no events in an enabled class, or
an unacknowledged restart); the retry policy stays the caller's via
Outstation.confirmTimedOut.
Responses are built/parsed symmetrically with
application.encodeResponseHeader/decodeResponseHeader plus the relevant
objects.gNN.VN.encode/.decode for each point record. See
src/root.zig's tests for a full master-builds/outstation-parses and
outstation-builds/master-parses round trip, including multi-frame
segmentation for fragments bigger than one link frame's user-data limit.
zig build test-dnp3 # Debug
zig build test-dnp3 -Doptimize=ReleaseFast
zig fmt --check modules/dnp3
142 offline tests: DNP3 CRC-16 known-answer vectors (the reveng
CRC-catalogue "CRC-16/DNP" check value plus additional vectors cross-checked
against an independent from-scratch bit-serial CRC reference — see SPEC.md),
data-link frame round-trips (empty/short/exact-block-boundary/multi-block user
data), transport segmentation + reassembly (including sequence-mismatch and
FIR-restart edge cases), object-header qualifier/range round-trips for every
implemented range shape, every object variation's encode/decode round-trip,
and a full link→transport→application stack round-trip in both directions
(master↔outstation). The outstation is tested against every function code it
implements, the IIN bits, the event confirm/retire/timeout cycle, buffer
overflow, select-before-operate in all four failure modes, and multi-fragment
responses. Every decode path is exercised with short/corrupt/garbage input to
confirm it returns a typed error rather than panicking, and four fuzz harnesses
drive fragments and link frames through the outstation and its Session: two
20 000-iteration loops over uniformly random bytes, and two over structured
draws (function code, group/variation, qualifier and range fields drawn from
each field's boundary values) that assert they reached the command path's range
arithmetic. The same generator backs two std.testing.fuzz targets.
Interop (2026-07-23). src/goldens.zig replays two sessions captured from
a live opendnp3 peer (built from source):
- opendnp3's
master-demo→ this outstation, over a real loopback TCP socket. Startup tasks, an integrity scan, an ad-hoc range scan, disable unsolicited, a CROB select+operate (opendnp3 reportedReceived command result w/ summary: SUCCESS), a class-1 exception scan across an injected process change, a cold restart (Success, Time: 100) and a second integrity scan — with no protocol warnings. - The same master against a fragmented response: 300 binary inputs with a 400-byte fragment limit, so the class-0 scan spans several fragments that opendnp3 confirms and reassembles one by one.
Replaying either session in order reproduces every reply byte for byte,
including the 48-bit event timestamps (the tests advance the same injected
clock the harness did). A third capture runs the reverse direction: this
module's master-side codecs against opendnp3's outstation-demo, which
cross-checks the pre-existing parsers against a third-party encoder.
Independent dissection. The captured session was written to a pcap and
dissected with Wireshark 4.6.4's DNP3 dissector via rawshark. All 43 frames
dissected cleanly with zero malformed or expert-error indications, and every
field matched: DIR/PRM, link addresses, application function codes, FIR/FIN/
CON/SEQ, the IIN restart and class-1 bits, and every object identifier in the
class-0 response (0x0102, 0x0302, 0x0a02, 0x1401, 0x1501, 0x1e01, 0x2801).
Two real interop bugs were found and fixed this way — the outstation was setting the link-layer DIR bit as if it were the master (opendnp3 logged "master frame received for master" and dropped every reply), and continuation fragments were reusing the request's application sequence number instead of incrementing it ("Response with bad sequence").
Provenance: clean-room from IEEE 1815-2012 (DNP3), an open standard. Design
references (behavioral/structural only, no source consulted or copied):
opendnp3 (Apache-2.0) and public DNP3 primers; the g120 Secure-Authentication
variation numbers and field layouts were cross-checked against the Wireshark
DNP3 dissector (packet-dnp.c, GPL-2.0) as a wire-format reference, no code
copied or adapted. See NOTICE beside this file.