From 0506de4005f3467d6c4b2ab28ea31174150f28e5 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 12 Oct 2026 22:43:14 +0000 Subject: [PATCH 1/2] docs: restructure README for pub.dev; community files; self-contained example - README: badges, installation from pub.dev, quick start, examples table, platform matrix (CI-tested vs. mapped-only targets), contributing and support sections; detailed guides moved to doc/master.md, doc/outstation.md, doc/architecture.md and doc/benchmarks.md - example/example.dart: outstation + master in one process (pub.dev Example tab), example/README.md - CONTRIBUTING.md (setup, checks, ABI sync rules, generators, releases), CODE_OF_CONDUCT.md (Contributor Covenant 2.1), SECURITY.md - issue forms (bug, feature), issue template config, PR template - CHANGELOG: Unreleased section, policy note, breaking changes marked Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01AVXyRT6zJUzzD6oSDcfG9W --- .github/ISSUE_TEMPLATE/bug_report.yml | 64 +++ .github/ISSUE_TEMPLATE/config.yml | 11 + .github/ISSUE_TEMPLATE/feature_request.yml | 27 ++ .github/pull_request_template.md | 16 + CHANGELOG.md | 18 +- CODE_OF_CONDUCT.md | 132 ++++++ CONTRIBUTING.md | 161 +++++++ README.md | 488 +++++---------------- SECURITY.md | 36 ++ doc/architecture.md | 59 +++ doc/benchmarks.md | 74 ++++ doc/master.md | 123 ++++++ doc/outstation.md | 46 ++ example/README.md | 25 ++ example/example.dart | 57 +++ 15 files changed, 949 insertions(+), 388 deletions(-) create mode 100644 .github/ISSUE_TEMPLATE/bug_report.yml create mode 100644 .github/ISSUE_TEMPLATE/config.yml create mode 100644 .github/ISSUE_TEMPLATE/feature_request.yml create mode 100644 .github/pull_request_template.md create mode 100644 CODE_OF_CONDUCT.md create mode 100644 CONTRIBUTING.md create mode 100644 SECURITY.md create mode 100644 doc/architecture.md create mode 100644 doc/benchmarks.md create mode 100644 doc/master.md create mode 100644 doc/outstation.md create mode 100644 example/README.md create mode 100644 example/example.dart diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml new file mode 100644 index 0000000..0824372 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -0,0 +1,64 @@ +name: Bug report +description: Something does not work as documented +labels: [bug] +body: + - type: markdown + attributes: + value: | + Thanks for reporting! Security problems must not be reported here: see SECURITY.md. + If the problem is in the DNP3 protocol behavior of the Rust stack itself, please also + check https://github.com/stepfunc/dnp3. + - type: textarea + id: what + attributes: + label: What happened? + description: What you did, what you expected and what happened instead. + validations: + required: true + - type: textarea + id: repro + attributes: + label: Minimal reproduction + description: | + A small program that shows the problem, ideally an outstation and a master in one + file like example/example.dart. + render: dart + validations: + required: true + - type: textarea + id: logs + attributes: + label: Logs and errors + description: | + Exceptions with their `detail`, and native logs if relevant + (`Dnp3.enableLogging(level: Dnp3LogLevel.debug)` and a `DecodeLevel` on the channel). + render: text + - type: input + id: version + attributes: + label: Package version + placeholder: "0.3.0" + validations: + required: true + - type: dropdown + id: platform + attributes: + label: Platform + multiple: true + options: [Android, iOS, macOS, Linux, Windows] + validations: + required: true + - type: textarea + id: env + attributes: + label: Environment + description: Output of `dart --version` or `flutter --version`, Rust version (`rustc -V`), device / OS version, peer DNP3 device if any. + render: text + - type: dropdown + id: native + attributes: + label: How is the native library obtained? + options: + - Built by the build hook (Rust toolchain) + - Prebuilt libraries (DNP3_PREBUILT_DIR / prebuilt_dir) + - Not sure diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000..1f477c3 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,11 @@ +blank_issues_enabled: true +contact_links: + - name: Security vulnerability + url: https://github.com/gencto/dnp3_plugin/security/advisories/new + about: Please report security problems privately. + - name: DNP3 protocol stack (upstream) + url: https://github.com/stepfunc/dnp3/issues + about: Protocol behavior of the underlying Rust library. + - name: Documentation and API reference + url: https://pub.dev/documentation/dnp3/latest/ + about: Every public member of the package is documented. diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml new file mode 100644 index 0000000..c720e59 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -0,0 +1,27 @@ +name: Feature request +description: Suggest an improvement or a missing part of the DNP3 API +labels: [enhancement] +body: + - type: textarea + id: problem + attributes: + label: What problem would this solve? + description: The use case, e.g. the device or system you are integrating with. + validations: + required: true + - type: textarea + id: proposal + attributes: + label: Proposed API or behavior + description: How you would like to use it. A Dart snippet helps. + render: dart + - type: textarea + id: alternatives + attributes: + label: Alternatives considered + - type: checkboxes + id: contribute + attributes: + label: Contribution + options: + - label: I would like to implement this (see CONTRIBUTING.md) diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 0000000..b9855f3 --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,16 @@ +## What and why + + + +## How was it tested? + + + +## Checklist + +- [ ] `dart analyze --fatal-infos` and `dart format -l 110` are clean +- [ ] `dart test` passes; new behavior is covered by a test +- [ ] Rust: `cargo fmt --check`, `cargo clippy --all-targets -- -D warnings`, `cargo test` (if `native/` changed) +- [ ] New public API has dartdoc comments +- [ ] `CHANGELOG.md` has an entry under `## Unreleased` (if users notice the change) +- [ ] Dart ↔ Rust interface changes: bindings, `protocol.dart` / `core.rs`, `doc/wire_format.md` and the ABI version updated (see CONTRIBUTING.md) diff --git a/CHANGELOG.md b/CHANGELOG.md index 43d1f30..3eadb6d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,19 @@ # Changelog +All notable changes to this package are documented here. Versions follow +[semantic versioning](https://semver.org/); while the version is `0.x`, a minor version may +contain breaking changes, which are marked **Breaking**. New entries go under +`## Unreleased` (see [CONTRIBUTING.md](CONTRIBUTING.md)). + +## Unreleased + +* Docs: README restructured for pub.dev (installation from pub.dev, platform support matrix, + examples table); the detailed guides moved to `doc/master.md`, `doc/outstation.md`, + `doc/architecture.md` and `doc/benchmarks.md`. +* New `example/example.dart`: a self-contained outstation + master demo. +* Community files: `CONTRIBUTING.md`, `CODE_OF_CONDUCT.md`, `SECURITY.md`, issue and pull + request templates. + ## 0.3.0 * `MasterServer` releases the channel of an accepted connection when its session ends and @@ -17,7 +31,7 @@ * `Dnp3.closeIsolate` / `Dnp3.shutdown` close the event streams of the isolate's objects; later calls fail with `ShutdownException`. * `MeasurementWorker` acknowledges a fragment even if the handler throws. -* Internal helpers (`decodeResults`, `decodeAssociationMessage`, `decodeAttribute`, +* **Breaking:** internal helpers (`decodeResults`, `decodeAssociationMessage`, `decodeAttribute`, `encodeAttribute`, the `ClientEvents` dispatch methods) are no longer part of the public API. * A Dart restart callback that throws is answered as "not supported" (it was reported to @@ -53,7 +67,7 @@ * Measurement back-pressure: `maxInFlightFragments`, `Association.acknowledge`, automatic acknowledgement in `MeasurementWorker`. * `Association.readFile(onOpened:)` decides whether to continue once the size is known. -* `Dnp3Exception.detail` with the structured library error. `TimeSyncException.detail` +* `Dnp3Exception.detail` with the structured library error. **Breaking:** `TimeSyncException.detail` (int) was renamed to `value`. * ABI version 2. diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 0000000..2ab63e4 --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,132 @@ +# Contributor Covenant Code of Conduct + +## Our Pledge + +We as members, contributors, and leaders pledge to make participation in our +community a harassment-free experience for everyone, regardless of age, body +size, visible or invisible disability, ethnicity, sex characteristics, gender +identity and expression, level of experience, education, socio-economic status, +nationality, personal appearance, race, caste, color, religion, or sexual +identity and orientation. + +We pledge to act and interact in ways that contribute to an open, welcoming, +diverse, inclusive, and healthy community. + +## Our Standards + +Examples of behavior that contributes to a positive environment for our +community include: + +* Demonstrating empathy and kindness toward other people +* Being respectful of differing opinions, viewpoints, and experiences +* Giving and gracefully accepting constructive feedback +* Accepting responsibility and apologizing to those affected by our mistakes, + and learning from the experience +* Focusing on what is best not just for us as individuals, but for the overall + community + +Examples of unacceptable behavior include: + +* The use of sexualized language or imagery, and sexual attention or advances of + any kind +* Trolling, insulting or derogatory comments, and personal or political attacks +* Public or private harassment +* Publishing others' private information, such as a physical or email address, + without their explicit permission +* Other conduct which could reasonably be considered inappropriate in a + professional setting + +## Enforcement Responsibilities + +Community leaders are responsible for clarifying and enforcing our standards of +acceptable behavior and will take appropriate and fair corrective action in +response to any behavior that they deem inappropriate, threatening, offensive, +or harmful. + +Community leaders have the right and responsibility to remove, edit, or reject +comments, commits, code, wiki edits, issues, and other contributions that are +not aligned to this Code of Conduct, and will communicate reasons for moderation +decisions when appropriate. + +## Scope + +This Code of Conduct applies within all community spaces, and also applies when +an individual is officially representing the community in public spaces. +Examples of representing our community include using an official email address, +posting via an official social media account, or acting as an appointed +representative at an online or offline event. + +## Enforcement + +Instances of abusive, harassing, or otherwise unacceptable behavior may be +reported to the community leaders responsible for enforcement by contacting the +maintainer, [@gencto](https://github.com/gencto), privately on GitHub. +All complaints will be reviewed and investigated promptly and fairly. + +All community leaders are obligated to respect the privacy and security of the +reporter of any incident. + +## Enforcement Guidelines + +Community leaders will follow these Community Impact Guidelines in determining +the consequences for any action they deem in violation of this Code of Conduct: + +### 1. Correction + +**Community Impact**: Use of inappropriate language or other behavior deemed +unprofessional or unwelcome in the community. + +**Consequence**: A private, written warning from community leaders, providing +clarity around the nature of the violation and an explanation of why the +behavior was inappropriate. A public apology may be requested. + +### 2. Warning + +**Community Impact**: A violation through a single incident or series of +actions. + +**Consequence**: A warning with consequences for continued behavior. No +interaction with the people involved, including unsolicited interaction with +those enforcing the Code of Conduct, for a specified period of time. This +includes avoiding interactions in community spaces as well as external channels +like social media. Violating these terms may lead to a temporary or permanent +ban. + +### 3. Temporary Ban + +**Community Impact**: A serious violation of community standards, including +sustained inappropriate behavior. + +**Consequence**: A temporary ban from any sort of interaction or public +communication with the community for a specified period of time. No public or +private interaction with the people involved, including unsolicited interaction +with those enforcing the Code of Conduct, is allowed during this period. +Violating these terms may lead to a permanent ban. + +### 4. Permanent Ban + +**Community Impact**: Demonstrating a pattern of violation of community +standards, including sustained inappropriate behavior, harassment of an +individual, or aggression toward or disparagement of classes of individuals. + +**Consequence**: A permanent ban from any sort of public interaction within the +community. + +## Attribution + +This Code of Conduct is adapted from the [Contributor Covenant][homepage], +version 2.1, available at +[https://www.contributor-covenant.org/version/2/1/code_of_conduct.html][v2.1]. + +Community Impact Guidelines were inspired by +[Mozilla's code of conduct enforcement ladder][Mozilla CoC]. + +For answers to common questions about this code of conduct, see the FAQ at +[https://www.contributor-covenant.org/faq][FAQ]. Translations are available at +[https://www.contributor-covenant.org/translations][translations]. + +[homepage]: https://www.contributor-covenant.org +[v2.1]: https://www.contributor-covenant.org/version/2/1/code_of_conduct.html +[Mozilla CoC]: https://github.com/mozilla/diversity +[FAQ]: https://www.contributor-covenant.org/faq +[translations]: https://www.contributor-covenant.org/translations diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..8fd80c6 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,161 @@ +# Contributing to dnp3 + +Thanks for helping! This document explains how the project is organized, how to build and +test it, and what a pull request needs to be merged. By participating you agree to the +[Code of Conduct](CODE_OF_CONDUCT.md). + +## Ways to help + +- **Report bugs** with the [bug report form](https://github.com/gencto/dnp3_plugin/issues/new/choose). + A minimal program that reproduces the problem (an outstation + master in one file, like + [`example/example.dart`](example/example.dart)) is the most useful thing you can attach. +- **Test on your platform / device** — especially targets marked "untested" in the README, + real serial hardware and third-party DNP3 devices — and report what works. +- **Improve documentation** (README, `doc/`, dartdoc comments, examples). +- **Fix bugs or add features.** For anything larger than a small fix, open an issue first so + the design can be agreed on before you spend time on it. + +Protocol behavior of the DNP3 stack itself (what the master or outstation sends on the +wire) comes from the [`dnp3`](https://github.com/stepfunc/dnp3) Rust library; issues there +belong upstream. This repository is the Dart API and the FFI bridge around it. + +## Development setup + +You need: + +- Dart SDK ≥ 3.10 (or Flutter ≥ 3.38 for the Flutter example) +- Rust stable via [rustup](https://rustup.rs) (the minimum supported Rust version is 1.85) +- Linux only, for the serial test: `python3` (it creates a pseudo-terminal null-modem) +- Optional, for the TLS test: `openssl` + +```sh +git clone https://github.com/gencto/dnp3_plugin && cd dnp3_plugin +dart pub get +dart test # the build hook compiles native/ on first run +``` + +The first `dart test` / `dart run` compiles the Rust crate in release mode with LTO and takes +a few minutes; later runs reuse the build. + +## Repository layout + +``` +lib/dnp3.dart public API (exports) +lib/src/ Dart implementation +lib/src/native/ @Native bindings, per-isolate dispatcher, binary writer +native/ Rust crate `dnp3_dart`: C ABI over the dnp3 crate +hook/build.dart build hook: cargo build per target, or prebuilt libraries +test/ integration tests (real master/outstation pairs) +example/ CLI examples and a Flutter app +benchmark/ benchmark suite and recorded results +doc/ guides and the wire format between Dart and Rust +tool/ code generators (variation tables) and small checks +``` + +[`doc/architecture.md`](doc/architecture.md) explains how calls, events and callbacks +travel between Dart and Rust; read it before changing the bridge. + +## Checks (what CI runs) + +Run these before opening a pull request; CI fails on any of them. + +```sh +# Dart +dart analyze --fatal-infos +dart format -l 110 --output=none --set-exit-if-changed lib test hook benchmark \ + example/example.dart example/master.dart example/outstation.dart example/worker_isolate.dart +dart test --timeout 2x + +# Rust +cd native +cargo fmt --check +cargo clippy --all-targets --locked -- -D warnings +cargo test --locked +``` + +CI additionally runs the Dart tests on Linux x64/arm64, macOS and Windows, builds every +native target and builds the Flutter example for all five platforms. + +## Coding guidelines + +- **Format:** `dart format -l 110` and `cargo fmt`. +- **Documentation:** every public Dart member needs a `///` comment (`public_member_api_docs` + is enforced). Say what the member does in DNP3 terms, units, ranges and what is thrown. +- **Validate in Dart:** values that would be truncated on the wire (indices, `u8` / `u16` / + `u32` fields) must throw `RangeError` instead of wrapping. +- **No panics across FFI:** every `extern "C"` function goes through `run()` / `guard()` in + `native/src/core.rs`, which catch panics and turn errors into return codes. +- **Hot paths stay binary:** measurements, database ops, commands and read requests use the + formats in [`doc/wire_format.md`](doc/wire_format.md). JSON is only for configuration. +- **Tests:** add a test for every fix or feature. Test files run concurrently in one process, + so do not assert on process-wide state such as `Dnp3.objectCounts`; check your own handles + instead. Wait for asynchronous events with `eventually(...)` from `test/test_utils.dart` + instead of fixed delays. + +## Changing the Dart ↔ Rust interface + +The Dart bindings and the native library must always match: + +1. Add or change the function in `native/src/lib.rs` and its declaration in + `lib/src/native/bindings.dart`. Leaf calls (`isLeaf: true`) must never block or call back + into Dart. +2. Keep message kinds, error codes and constants in sync between `native/src/core.rs` and + `lib/src/native/protocol.dart`. +3. Update [`doc/wire_format.md`](doc/wire_format.md) for any binary format change. +4. If the change is incompatible (a removed or changed function, a changed format), bump + `ABI_VERSION` in `core.rs` **and** `abiVersion` in `protocol.dart`. The Dart side refuses + to load a library with a different ABI version, which protects users of prebuilt + libraries. + +### Generated code + +`lib/src/variation.dart` and `native/src/variation.rs` are generated from the dnp3 crate: + +```sh +python3 tool/extract_variations.py ~/.cargo/registry/src/*/dnp3-1.7.0/src/app/variations.rs vars.json +python3 tool/gen_variations_rs.py vars.json native/src/variation.rs +python3 tool/gen_variations_dart.py vars.json lib/src/variation.dart +dart format -l 110 lib/src/variation.dart +``` + +Edit the generators, not the generated files. + +### Upgrading the dnp3 crate + +The crate is pinned (`=1.7.0`) on purpose. An upgrade means: bump it in `native/Cargo.toml`, +regenerate the variation tables, fix the bridge for API changes, run the full test suite and +the benchmarks, and update the version mentioned in the README and `doc/`. + +## Pull requests + +- Keep a pull request focused on one topic; small PRs are reviewed faster. +- Add an entry under `## Unreleased` in [CHANGELOG.md](CHANGELOG.md) for anything users + notice (fixes, features, API or behavior changes, ABI bumps). +- Describe *why* in the PR description and how you tested it (platforms, devices). +- All CI checks must be green before merging. + +Commit messages follow a light [Conventional Commits](https://www.conventionalcommits.org/) +style: `fix: ...`, `feat: ...`, `docs: ...`, `test: ...`, `build: ...`, `chore: ...`, with an +optional scope such as `fix(native): ...`. + +## Benchmarks + +Changes to hot paths (measurement encoding / decoding, database transactions, the port +dispatcher) should come with before / after numbers: + +```sh +dart run benchmark/run_all.dart --quick --out /tmp/before.md # on main +dart run benchmark/run_all.dart --quick --out /tmp/after.md # on your branch +``` + +## Releasing (maintainers) + +1. Move the `## Unreleased` entries in `CHANGELOG.md` under a new version heading. +2. Bump `version` in `pubspec.yaml` and `native/Cargo.toml`, then `(cd native && cargo update -p dnp3_dart)`. +3. Run the checks above and `dart pub publish --dry-run`. +4. Merge, then `dart pub publish`. +5. Tag `vX.Y.Z` and push the tag: the `release` workflow builds every native target and + attaches `dnp3-native-prebuilt.zip` to a GitHub release. + +Versions follow [semantic versioning](https://semver.org/); while the version is `0.x`, a +minor bump may contain breaking changes, which are called out in the changelog. diff --git a/README.md b/README.md index 73fc4ad..31d5985 100644 --- a/README.md +++ b/README.md @@ -1,107 +1,57 @@ -# dnp3 — DNP3 (IEEE 1815) for Dart & Flutter - -A complete DNP3 **master** and **outstation** for Dart and Flutter, built on the -[`dnp3`](https://github.com/stepfunc/dnp3) Rust library by Step Function I/O (v1.7.0). - -* every transport of the library: **TCP client/server, TLS client/server, serial, UDP**, and a - **master acting as TCP server** (outstations dial in, identified by IP or link address) -* the full master feature set: associations, class/range/attribute reads, periodic polls, - SBO/DO/DO-no-ack commands (CROB, g41v1–v4), time sync (LAN, non-LAN, direct write), cold/warm - restart, dead band writes, freeze/write/enable-/disable-unsolicited via generic requests, - link status, device attributes (group 0), file transfer (auth, open, read stream, write - blocks, close, directory listing, file info), association information callbacks -* the full outstation feature set: database of BI, DBBI, BOS, counters, frozen counters, AI, - AOS and octet strings with event classes, dead bands and every static/event variation, - flag updates, device attributes, control handling (native or Dart callbacks), application - callbacks (time write, restarts, freeze, attribute writes, dead bands, confirmations), - application IIN, information callbacks, address filters on TCP/TLS servers -* **zero-copy** data paths in both directions and **isolate-friendly** handles -* Android, iOS, macOS, Linux, Windows (Web is not possible: no sockets / FFI) - +# dnp3 + +[![pub package](https://img.shields.io/pub/v/dnp3.svg)](https://pub.dev/packages/dnp3) +[![CI](https://github.com/gencto/dnp3_plugin/actions/workflows/ci.yml/badge.svg?branch=main)](https://github.com/gencto/dnp3_plugin/actions/workflows/ci.yml) +[![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE) + +DNP3 (IEEE 1815) **master** and **outstation** for Dart and Flutter, built on the +[`dnp3`](https://github.com/stepfunc/dnp3) Rust library by Step Function I/O (v1.7.0) +through a zero-copy FFI bridge. + +- **Every transport:** TCP client/server, TLS client/server, serial, UDP, and a master + acting as TCP server (outstations dial in, identified by IP or link address). +- **Complete master:** associations, class/range/attribute reads, periodic polls, + SBO/DO commands (CROB, g41v1–v4), time sync, restarts, dead bands, generic requests, + device attributes, file transfer, reconnect policy in Dart, measurement back-pressure. +- **Complete outstation:** database of all point types with event classes, dead bands and + every variation, native or Dart control handling, application callbacks (time, restart, + freeze, attributes), address filters, FREEZE_AT_TIME scheduling. +- **Fast:** measurements are read in place from native buffers (zero-copy flyweights), + database updates are batched into one native call, handles work from any isolate. +- **Platforms:** Android, iOS, macOS, Linux, Windows. (Web is impossible: no sockets, no FFI.) + +> [!IMPORTANT] > **Licensing.** This package's own code is MIT licensed, but the `dnp3` Rust library it > links is distributed by Step Function I/O under a **non-commercial license**. Production > or commercial use requires a commercial license from Step Function I/O -> (). Make sure you are licensed before shipping an app built with this -> package. - ---- +> (). Make sure you are licensed before shipping an app built with +> this package. ## Contents -- [Architecture](#architecture) -- [Installation & platforms](#installation--platforms) +- [Installation](#installation) - [Quick start](#quick-start) -- [Master API](#master-api) -- [Outstation API](#outstation-api) -- [Isolates](#isolates) -- [Zero-copy in detail](#zero-copy-in-detail) -- [Logging & errors](#logging--errors) -- [Benchmarks](#benchmarks) -- [Testing](#testing) -- [CI/CD](#cicd) -- [Repository layout](#repository-layout) - -## Architecture - -``` - Dart isolate(s) │ native (Rust, one per process) -─────────────────────────────────────────┼────────────────────────────────────────────── - MasterChannel / Association / Poll │ dnp3_dart (native/) - Outstation / OutstationServer / Database │ ├─ C ABI (lib.rs), handle registry - MasterServer │ ├─ tokio multi-thread runtime - │ @Native leaf calls │ ├─ dnp3 1.7.0 master / outstation tasks - │ (Uint8List.address → no copy) ─────►│ ├─ ReadHandler → binary fragment encoder - │ │ ├─ ControlHandler / OutstationApplication / - │◄──── Dart_PostCObject ───────────────│ │ OutstationInformation / AssociationInformation - │ [kind, id, sub, payload] │ │ → port messages, sync/async request-reply - │ payload: external typed data │ └─ tracing → package:logging - RawReceivePort per isolate │ -``` - -* **Handles are integers.** Every native object (channel, association, poll, outstation, - server) lives in a process-wide registry and is referenced by a 64-bit handle that can be - sent to any isolate and re-attached (`Association.attach(handle)`, `Outstation.attach`, - `Database(handle)`). -* **One `RawReceivePort` per isolate** receives completions, events and callback requests. - Async operations complete through it; events are routed to the owning Dart object. -* **Callbacks that DNP3 requires to be synchronous** (outstation SELECT/OPERATE, restart, - time write, freeze) are executed natively by default (`ControlMode.accept`, - `FreezeMode.auto`, ...). When configured as `callback`, the protocol worker posts a request - to Dart and parks itself with `block_in_place` (other sessions keep running) until Dart - replies or a configurable timeout expires. Replies can carry database operations which are - applied atomically before the response is sent. -* **Configuration** objects are serialized to JSON once (cold path); all hot paths - (measurements, database updates, commands, read requests) use compact little-endian binary - formats documented in [`doc/wire_format.md`](doc/wire_format.md). +- [Examples](#examples) +- [Documentation](#documentation) +- [Platform support](#platform-support) +- [Contributing](#contributing) +- [Support and security](#support-and-security) -## Installation & platforms +## Installation -```yaml -dependencies: - dnp3: - git: https://github.com/gencto/dnp3_plugin +```sh +dart pub add dnp3 # or: flutter pub add dnp3 ``` -The native library is produced by the package's **build hook** (`hook/build.dart`, Dart -native assets) for whatever target `dart` or `flutter` is building for: - -| Platform | Targets (Rust triples) | Serial | Notes | -|---|---|---|---| -| Android | `aarch64-linux-android`, `armv7-linux-androideabi`, `x86_64-linux-android`, `i686-linux-android` | ✓ | NDK linker from the Flutter tool, 16 KB page aligned | -| iOS | `aarch64-apple-ios`, `aarch64-apple-ios-sim`, `x86_64-apple-ios` | ✗ | serial ports do not exist on iOS | -| macOS | `aarch64-apple-darwin`, `x86_64-apple-darwin` | ✓ | | -| Linux | `x86_64`, `aarch64`, `armv7`, `i686`, `riscv64gc` | ✓ | | -| Windows | `x86_64-pc-windows-msvc`, `aarch64-pc-windows-msvc`, `i686` | ✓ | | -| Web | — | — | impossible (no sockets, no FFI) | +The native library is built by the package's build hook (Dart native assets) for whatever +target `dart` or `flutter` builds for. You need **Dart ≥ 3.10 / Flutter ≥ 3.38** and either: -Requirements: Dart ≥ 3.10 / Flutter ≥ 3.38 (build hooks), and either - -1. a Rust toolchain (`rustup target add ` is done automatically when rustup is - present), **or** -2. prebuilt libraries: unzip `dnp3-native-prebuilt.zip` from a GitHub release (built by the - `release` workflow) into `native/prebuilt/`, or point the environment variable - `DNP3_PREBUILT_DIR` (or the hook user-define `prebuilt_dir`) to it. Layout: - `//`. +1. a **Rust toolchain** (); the hook runs `rustup target add` for the + target automatically, or +2. **prebuilt libraries**: download `dnp3-native-prebuilt.zip` from a + [GitHub release](https://github.com/gencto/dnp3_plugin/releases) and point the + environment variable `DNP3_PREBUILT_DIR` (or the hook user-define `prebuilt_dir`) to the + extracted folder. Layout: `//`. ## Quick start @@ -109,31 +59,27 @@ Requirements: Dart ≥ 3.10 / Flutter ≥ 3.38 (build hooks), and either import 'package:dnp3/dnp3.dart'; Future main() async { - // --- outstation (TCP server) --- + // outstation: TCP server with a few points final server = OutstationServer.tcp('0.0.0.0:20000'); final outstation = server.addOutstation( config: const OutstationConfig(outstationAddress: 1024, masterAddress: 1), ); - outstation.transaction((tx) { - tx - ..addAnalogInput(0, deadband: 0.5) - ..addBinaryOutputStatus(0) - ..updateAnalogInput(0, 42.0, time: Dnp3Time.now()); - }); + outstation.transaction((tx) => tx + ..addAnalogInput(0, deadband: 0.5) + ..addBinaryOutputStatus(0) + ..updateAnalogInput(0, 42.0, time: Dnp3Time.now())); await server.bind(); - // --- master (TCP client) --- + // master: TCP client polling the outstation final channel = MasterChannel.tcpClient( config: const MasterChannelConfig(masterAddress: 1), endpoints: ['127.0.0.1:20000'], ); final assoc = await channel.addAssociation(1024); assoc.measurements.listen((fragment) { - for (final block in fragment.blocks) { - if (block is AnalogInputBlock) { - for (var i = 0; i < block.count; i++) { - print('AI ${block.indexAt(i)} = ${block.valueAt(i)}'); // read in place, no copy - } + for (final block in fragment.blocks.whereType()) { + for (var i = 0; i < block.count; i++) { + print('AI ${block.indexAt(i)} = ${block.valueAt(i)}'); // read in place, no copy } } }); @@ -144,304 +90,74 @@ Future main() async { } ``` -More: [`example/master.dart`](example/master.dart), [`example/outstation.dart`](example/outstation.dart), -[`example/worker_isolate.dart`](example/worker_isolate.dart) and the Flutter app in -[`example/flutter_app`](example/flutter_app). - -## Master API - -```dart -// channels -MasterChannel.tcpClient(config:, endpoints:, linkErrorMode:, connectStrategy:, connectOptions:) -MasterChannel.tlsClient(config:, endpoints:, tls: TlsConfig(...)) -MasterChannel.serial(config:, path: '/dev/ttyUSB0', settings: SerialSettings(baudRate: 9600)) -MasterChannel.udp(config:, localEndpoint: '0.0.0.0:20000') -channel.clientStates / portStates / connectionEvents // streams -channel.enable() / disable() / setDecodeLevel() / getDecodeLevel() / destroy() - -// associations -final a = await channel.addAssociation(1024, - config: AssociationConfig(autoTimeSync: TimeSyncProcedure.lan, keepAliveTimeout: ...), - udpDestination: '10.0.0.5:20000', // UDP channels only - information: true, // task start/success/fail, unsolicited events - eventPort: worker.port); // deliver to another isolate - -a.measurements // Stream (zero-copy) -a.information // Stream -await a.read(ReadRequest.integrity() | .events() | .classScan(Classes(...)) - | .range(Variation.group30Var5, 0, 9) | .headers([...]) | .deviceAttribute(v)); -await a.readWithHandler(request) // returns List -await a.operate(CommandSet()..addCrob(0, Crob(ControlCode.latchOn()))..addAnalog(1, AnalogOutput.float32(5)), - mode: CommandMode.selectBeforeOperate | directOperate); -await a.synchronizeTime(TimeSyncProcedure.nonLan); -await a.coldRestart() / warmRestart(); // → Duration reported by the outstation -await a.writeDeadBands([DeadBandHeader.g34v3({0: 1.5})]); -await a.freezeCounters(clear: true); -await a.enableUnsolicited(EventClasses.all()) / disableUnsolicited(...); -await a.writeAttribute(DeviceAttribute.string(AttributeVariation.userAssignedLocation, 'Kyiv')); -await a.sendAndExpectEmptyResponse(FunctionCode.freezeAtTime, - RequestHeaders()..timeAndInterval(Dnp3Time.now(), 60000)..allObjects(Variation.group20Var0)); -await a.checkLinkStatus(); -final poll = await a.addPoll(ReadRequest.events(), Duration(seconds: 1)); poll.demand(); poll.remove(); -a.setTimeOffset(Duration(hours: -1)); -a.setTimeSource(TimeSource.manual(gpsTime)); // or TimeSource.system(offset) / TimeSource.unavailable() - -// files -final key = await a.getFileAuthKey('user', 'pass'); -final f = await a.openFile('/log.txt', authKey: key, mode: FileMode.read); -a.readFile('/log.txt', onOpened: (size) => size < 1 << 20).blocks.listen(...); // streaming, abortable -await a.writeFile('/cfg.bin', bytes); // open + blocks + close -await a.readDirectory('/'); await a.getFileInfo('/x'); - -// reconnect logic in Dart (TCP / TLS masters and outstation clients) -MasterChannel.tcpClient(..., connectionPolicy: MyPolicy()); // next() → connect(endpoint) | wait(delay) - -// master as TCP server (outstations connect to us) -final server = await MasterServer.bind('0.0.0.0:20000', MyHandler()); // accept / start / acceptLinkId -``` - -`MeasurementFragment` exposes the response header (`readType`, `iin`, `sequence`, `fir/fin/con/uns`) -and its object headers as typed `MeasurementBlock`s: `BinaryInputBlock`, -`DoubleBitBinaryInputBlock`, `BinaryOutputStatusBlock`, `CounterBlock`, `FrozenCounterBlock`, -`AnalogInputBlock`, `FrozenAnalogInputBlock`, `AnalogInputDeadBandBlock`, -`AnalogOutputStatusBlock`, `AnalogOutputCommandEventBlock`, `BinaryOutputCommandEventBlock`, -`UnsignedIntegerBlock`, `OctetStringBlock`, `DeviceAttributeBlock`, `AbsTimeBlock`. Each block -knows its variation, qualifier, `isEvent` and `hasFlags`, and is both a lazily decoded -`List` and a flyweight with `indexAt / valueAt / flagsAt / timeAt` accessors. A -`MeasurementVisitor` dispatches all blocks of a fragment by type. - -### Flow control (back-pressure) - -```dart -final a = await channel.addAssociation(1024, maxInFlightFragments: 8, manualAck: true); -a.measurements.listen((f) async { - await store(f); // slow consumer - a.acknowledge(); // releases one slot -}); -``` - -With `maxInFlightFragments > 0` the native read handler suspends after posting that many -unacknowledged fragments. The master then stops reading the channel, so TCP flow control -slows the outstation down instead of letting fragments pile up in the Dart heap. Without -`manualAck` every fragment is acknowledged right after the synchronous listeners ran; -`MeasurementWorker` acknowledges after its callback. `backpressureTimeout` (default 30 s) -bounds the wait if a consumer stops acknowledging. - -### Connection policy - -```dart -class FailoverPolicy extends ConnectionPolicy { - @override - ConnectDecision next(ConnectContext c) => c.failures < 3 - ? const ConnectDecision.connect('10.0.0.1:20000') - : const ConnectDecision.connect('10.0.0.2:20000', timeout: Duration(seconds: 2)); - - @override - Duration disconnected(String address, String? host) => const Duration(seconds: 5); -} -``` - -Every method may be async. If no answer arrives within `callbackTimeout` (default 1 s), or -a method returns null, the native round-robin policy with exponential back-off decides. -`ConnectDecision.connect` can also set the connect timeout, the local endpoint and, for -outstation clients, the master address of that connection. - -### Error details - -Every exception carries `detail` with the structured library error, e.g. -`Link(Stdio(ConnectionReset))`, `MalformedResponse(InsufficientBytes)`, -`RejectedByIin2(Iin { ... })`. - -## Outstation API - -```dart -Outstation.tcpClient(..., masterAddress: 1, connectionPolicy: ...) / tlsClient(...) -Outstation.serial(path:, retry: RetryStrategy()) / Outstation.udp(...) -final server = OutstationServer.tcp('0.0.0.0:20000'); // or .tls(address, TlsConfig) -final o = server.addOutstation(config: ..., handlers: ..., filter: AddressFilter.wildcard('10.0.0.*')); -await server.bind(); - -o.transaction((tx) { // one native call, one lock, one change notification - tx.addCounter(0, eventClass: EventClass.class2, staticVariation: StaticCounterVariation.group20Var5); - tx.updateAnalogInput(3, 1.5, flags: Flags.online, time: Dnp3Time.now(), mode: EventMode.force); - tx.updateFlags(PointType.analogInput, 3, Flags.commLost); - tx.defineAttribute(DeviceAttribute.string(AttributeVariation.deviceManufacturerName, 'ACME')); - tx.removeBinaryInput(7); -}); -o.database.transactionWithResults(...) // per-op UpdateResult (event ids, overflow) -o.database.getAnalogInput(3) // point reads; getRange(PointType.counter, 0, 99) → zero-copy block -o.setApplicationIin(needTime: true, deviceTrouble: false); -o.events // ControlExecuted, TimeWritten, DeadBandsWritten, AttributeWritten, CountersFrozen, RestartRequested, EventsConfirmed -o.information // OutstationInformation callbacks -o.connectionStates / clientStates / portStates / connectionEvents -``` - -Handler configuration (`OutstationHandlers`): +## Examples -| Concern | Native modes | Dart callback | -|---|---|---| -| SELECT / OPERATE | `ControlMode.accept` (mirror into BOS / AOS, notify), `ControlMode.reject` + `rejectStatus` | `ControlMode.callback` + `onControl: (req) => ControlResponse(status, tx)` | -| cold / warm restart | `RestartMode.notSupported`, `seconds` / `milliseconds` + `coldRestartDelay` / `warmRestartDelay` | `RestartMode.callback` → `OutstationApplication.coldRestart()` | -| time write | `notSupported`, `accept` (+ event) | `TimeWriteMode.callback` | -| counter freeze | `notSupported`, `auto` (counter → frozen counter, freeze & clear, and FREEZE_AT_TIME: once, periodic, periodic relative to the hour — scheduled natively) | `FreezeMode.callback` → `(result, tx)` | -| request with controls | in `accept` mode all mirrored values are written in one transaction | `onControlFragmentBegin` / `onControlFragmentEnd` → `DatabaseTransaction` applied before the response | -| attribute write | `accept` (+ event), `reject` | `AttributeWriteMode.callback` (async) | -| dead band writes / confirms | notifications | — | - -Callback mode parks one protocol worker per pending request. Do not `await` operations on -the *same* outstation (enable/disable/decode level) inside a control callback: they are -queued behind the callback and only run after it returns (or times out). - -## Isolates - -* `MasterChannel.addAssociation(..., eventPort: worker.port)` — measurement fragments are - posted by native code **directly to the worker isolate**; the creating (UI) isolate is not - involved at all. `MeasurementWorker.spawn(callback)` creates such a worker. -* `Database(outstationHandle)` works in any isolate: e.g. a sampling isolate can feed the - outstation database at high rate without touching the UI isolate. -* `Association.attach(handle)`, `MasterChannel.attach(handle)`, `Poll.attach(handle)`, - `Outstation.attach(handle)` give full API access from other isolates; completions are - delivered to the isolate that issued the call. -* `DatabaseTransaction` can be built anywhere and shipped as bytes - (`TransferableTypedData`) to be applied with `database.applyBytes(...)`. - -## Zero-copy in detail - -| Path | Mechanism | +| Example | What it shows | |---|---| -| native → Dart measurements | one contiguous buffer per fragment; ≥ threshold it is posted as **external typed data** (the VM adopts the Rust allocation, freed by a finalizer). Dart reads it in place through `ByteData` flyweights. | -| Dart → native requests / database ops | encoded into a reusable Dart `Uint8List`; passed to `@Native(isLeaf: true)` functions via `Uint8List.address`, so Rust reads the Dart heap directly. | -| outstation range reads | native buffer exposed with `Pointer.asTypedList(finalizer:)`. | -| file blocks | external typed data views. | +| [`example/example.dart`](example/example.dart) | Outstation and master in one process: read, decode, control. Runs to completion. | +| [`example/outstation.dart`](example/outstation.dart) | TCP outstation with changing values, native control handling, logging. | +| [`example/master.dart`](example/master.dart) | Interactive TCP master: polls, CROB / analog commands, device attributes. | +| [`example/worker_isolate.dart`](example/worker_isolate.dart) | Measurements delivered straight into a worker isolate. | +| [`example/flutter_app`](example/flutter_app) | Flutter UI: in-app outstation or remote master, live values, controls. | -The external-vs-copy threshold defaults to **128 KiB**: the benchmarks below show that for -small buffers a copy into the Dart young generation is as cheap as the finalizer-based -transfer, while for large buffers zero-copy is up to 4× faster. Change it with -`Dnp3.zeroCopyThreshold = bytes` (`0` = always zero-copy). - -## Logging & errors - -```dart -Dnp3.logger.onRecord.listen(print); // package:logging -Dnp3.enableLogging(level: Dnp3LogLevel.info); -channel.setDecodeLevel(const DecodeLevel(application: AppDecodeLevel.objectValues)); +```sh +dart run example/example.dart +dart run example/outstation.dart 127.0.0.1:20000 # terminal 1 +dart run example/master.dart 127.0.0.1:20000 # terminal 2 (type: on 1, ao 2 12.5, attrs, quit) ``` -Errors are typed: `TaskException(TaskError.responseTimeout, ...)`, `CommandException` -(`status` for rejected controls), `TimeSyncException`, `WriteException` (IIN2), -`FileException`, `AssociationException`, `ConfigException`, `ShutdownException`, -`InvalidHandleException`, `NativeException`. +## Documentation -## Benchmarks +- **[Master API](doc/master.md)** — channels, associations, reads, commands, polls, files, + back-pressure, connection policy, master server, errors and logging. +- **[Outstation API](doc/outstation.md)** — servers and clients, database transactions, + control and application handlers. +- **[Architecture](doc/architecture.md)** — how the FFI bridge works, isolates, zero-copy + paths. +- **[Benchmarks](doc/benchmarks.md)** — delivery, decoding, database and end-to-end numbers. +- **[Wire format](doc/wire_format.md)** — binary formats exchanged with the native library. +- **[API reference](https://pub.dev/documentation/dnp3/latest/)** — every public member is + documented. +- **[Changelog](CHANGELOG.md)** -`dart run benchmark/run_all.dart [--quick] [--out benchmark/results/.md]` +## Platform support -Results below: Linux x64 VM, 4 vCPUs, Dart 3.13.5, release build with LTO -([`benchmark/results/linux-x64.md`](benchmark/results/linux-x64.md)). The CI patch runs the -same suite on Linux x64/arm64, macOS and Windows and publishes the tables in the job summary. - -### 1. Native → Dart delivery of measurement fragments - -| points / fragment | transfer | bytes / fragment | fragments / s | points / s | MB / s | µs / fragment | -|---|---|---|---|---|---|---| -| 10 | zero-copy (external typed data) | 272 | 161.6 k | 1.62 M | 44.0 | 6.19 | -| 10 | copied (kTypedData) | 272 | 150.3 k | 1.50 M | 40.9 | 6.65 | -| 100 | zero-copy (external typed data) | 2432 | 97.7 k | 9.77 M | 237.7 | 10.23 | -| 100 | copied (kTypedData) | 2432 | 64.7 k | 6.47 M | 157.3 | 15.46 | -| 1000 | zero-copy (external typed data) | 24.0 k | 29.8 k | 29.77 M | 715.3 | 33.59 | -| 1000 | copied (kTypedData) | 24.0 k | 36.5 k | 36.50 M | 877.2 | 27.39 | -| 4000 | zero-copy (external typed data) | 96.0 k | 16.2 k | 64.62 M | 1551.3 | 61.91 | -| 4000 | copied (kTypedData) | 96.0 k | 16.3 k | 65.11 M | 1563.1 | 61.44 | -| 16000 | zero-copy (external typed data) | 384.0 k | 7058.9 | 112.94 M | 2710.8 | 141.66 | -| 16000 | copied (kTypedData) | 384.0 k | 1705.0 | 27.28 M | 654.8 | 586.50 | -| 1000 | zero-copy + decode every value | - | 47.4 k | 47.37 M | - | - | - -(“µs / fragment” includes native encoding, the port hop and Dart dispatch. Up to ~100 KB -both transfers are within noise of each other; at 384 KB zero-copy is 4× faster.) - -### 2. Decoding cost per point (1000 analog inputs per fragment) - -| method | ns / point | points / s | -|---|---|---| -| flyweight accessor: value only | 2.64 | 378.43 M | -| MeasurementVisitor: value only | 2.85 | 350.82 M | -| flyweight accessors: index + value + flags + time | 7.75 | 129.07 M | -| value objects (List), all fields | 31.36 | 31.89 M | -| baseline: JSON bytes → objects, all fields | 693.14 | 1.44 M | - -The binary flyweight path is **~90× faster** than a JSON based bridge (as used by typical -`MethodChannel` plugins) for the same information, and allocation-free. - -### 3. Outstation database transactions (analog updates with event detection) - -| updates / transaction | updates / s | ns / update | transactions / s | +| Platform | Targets built and tested in CI | Also mapped by the hook (untested) | Serial | |---|---|---|---| -| 1 | 9.23 M | 108.3 | 9.23 M | -| 10 | 31.50 M | 31.7 | 3.15 M | -| 100 | 34.78 M | 28.8 | 347.8 k | -| 1000 | 26.60 M | 37.6 | 26.6 k | -| read (getAnalogInput) | 5.96 M | - | - | +| Android | `aarch64`, `armv7`, `x86_64`, `i686` (`*-linux-android`) | — | ✓ | +| iOS | `aarch64-apple-ios`, `aarch64-apple-ios-sim`, `x86_64-apple-ios` | — | ✗ | +| macOS | `aarch64-apple-darwin`, `x86_64-apple-darwin` | — | ✓ | +| Linux | `x86_64-unknown-linux-gnu`, `aarch64-unknown-linux-gnu` | `armv7`, `i686`, `riscv64gc` | ✓ | +| Windows | `x86_64-pc-windows-msvc`, `aarch64-pc-windows-msvc` | `i686-pc-windows-msvc` | ✓ | -### 4. End-to-end over TCP loopback (master and outstation in the same process) +CI runs the Dart test suite on Linux x64/arm64, macOS and Windows, builds all native +targets, and builds the Flutter example for Android, iOS, macOS, Linux and Windows. +Android libraries are 16 KB page aligned. -| operation | p50 µs | p99 µs | ops / s | -|---|---|---|---| -| integrity poll, 1000 analogs (g30v6), decoded | 536 | 1261 | 1738.2 | -| read 10 analogs (range) | 92 | 221 | 9946.9 | -| direct operate CROB (accept mode, native) | 112 | 310 | 8212.9 | -| select-before-operate CROB (accept mode) | 121 | 386 | 7103.1 | -| direct operate CROB (Dart callback round trip) | 137 | 370 | 6616.8 | -| select-before-operate CROB (2 Dart callbacks) | 243 | 721 | 3567.0 | +## Contributing -A synchronous Dart control callback adds ~25 µs (p50) per SELECT/OPERATE. +Contributions are welcome: bug reports, documentation, tests, platform fixes and features. +Start with **[CONTRIBUTING.md](CONTRIBUTING.md)**: it covers the development setup, the +checks CI runs, how the Dart ↔ Rust ABI is kept in sync and how releases are made. Please +follow the [Code of Conduct](CODE_OF_CONDUCT.md). -### 5. Event throughput outstation → master (unsolicited, TCP loopback) - -| events | ms | events / s | -|---|---|---| -| 200.0 k | 134 | 1.49 M | - -### 6. Delivery straight into a worker isolate (1000 points per fragment, decoded) - -| receiver | fragments / s | points / s | -|---|---|---| -| worker isolate | 69.1 k | 69.06 M | - -## Testing +Good first steps: ```sh -dart test # builds native/ through the hook, 54 tests -(cd native && cargo test) # Rust unit tests +git clone https://github.com/gencto/dnp3_plugin && cd dnp3_plugin +dart pub get +dart test # builds native/ through the hook, runs the integration suite +(cd native && cargo test) # Rust unit tests ``` -The integration suite runs real master/outstation pairs over TCP, TLS (self-signed -certificates generated with `openssl`), UDP, serial (a Python pty null-modem on Linux) and the -master-server mode, plus callbacks, device attributes, isolates, logging and handle cleanup. +## Support and security -## CI/CD +- **Questions and bugs:** [open an issue](https://github.com/gencto/dnp3_plugin/issues/new/choose). +- **Security vulnerabilities:** please report privately, see [SECURITY.md](SECURITY.md). +- **Issues in the DNP3 stack itself** (protocol behavior of the Rust library): see + [stepfunc/dnp3](https://github.com/stepfunc/dnp3). -GitHub Actions (`.github/workflows/`): +## License -* `ci.yml` — on pushes to `main` and pull requests: Rust fmt/clippy/tests; Dart - analyze/format/tests + a quick benchmark on Linux x64/arm64, macOS and Windows; native - builds for all 13 targets; Flutter example builds for Android, iOS, macOS, Linux and - Windows. -* `native-build.yml` — reusable matrix building every native target. -* `release.yml` — on `v*` tags: builds all targets, publishes `dnp3-native-prebuilt.zip`. -* `benchmark.yml` — weekly / manual full benchmark on all desktop OSes. -* `dependabot.yml` — weekly updates of actions, cargo and pub dependencies. - -## Repository layout - -``` -hook/build.dart build hook (cargo / prebuilt resolution) -native/ Rust bridge crate (C ABI over dnp3 1.7.0) -lib/dnp3.dart public API -lib/src/native/ @Native bindings, runtime dispatcher, binary writer -lib/src/measurements.dart zero-copy fragment / block views -benchmark/ benchmark suite and recorded results -test/ integration tests -example/ Dart CLI examples and a Flutter app -doc/wire_format.md binary formats exchanged with native code -tool/ code generators (variation tables) -``` +MIT for this package, see [LICENSE](LICENSE). The linked `dnp3` Rust library has its own +license, see the note at the top. diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..02ece42 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,36 @@ +# Security policy + +## Supported versions + +Security fixes are made for the latest released version on +[pub.dev](https://pub.dev/packages/dnp3). Please upgrade before reporting. + +## Reporting a vulnerability + +**Do not open a public issue for security problems.** Report them privately through GitHub: +go to the repository's **Security** tab and choose +[**Report a vulnerability**](https://github.com/gencto/dnp3_plugin/security/advisories/new). + +Please include: + +- the affected version and platform, +- what an attacker can do (e.g. crash or hang the process, read or corrupt memory, bypass an + address filter or TLS verification), +- a minimal reproduction (for example a DNP3 frame sequence or a small Dart program). + +You will get an acknowledgement within a few days. Once a fix is available it is released +and the advisory is published, crediting you unless you prefer otherwise. + +## Scope + +In scope: this package's Dart code, the FFI bridge in `native/` (memory safety, handle +lifetimes, data passed between Dart and Rust) and the build hook. + +Vulnerabilities in the DNP3 protocol implementation itself (parsing, TLS handling in the +stack) belong to the upstream [`dnp3`](https://github.com/stepfunc/dnp3) library by Step +Function I/O; please report those to them as well. We will update the pinned version once a +fix is released. + +DNP3 without TLS (or Secure Authentication) is not encrypted or authenticated by design. Use +TLS channels, network segmentation and the outstation address filters for untrusted +networks. diff --git a/doc/architecture.md b/doc/architecture.md new file mode 100644 index 0000000..6bb197b --- /dev/null +++ b/doc/architecture.md @@ -0,0 +1,59 @@ +# Architecture + +``` + Dart isolate(s) │ native (Rust, one per process) +─────────────────────────────────────────┼────────────────────────────────────────────── + MasterChannel / Association / Poll │ dnp3_dart (native/) + Outstation / OutstationServer / Database │ ├─ C ABI (lib.rs), handle registry + MasterServer │ ├─ tokio multi-thread runtime + │ @Native leaf calls │ ├─ dnp3 1.7.0 master / outstation tasks + │ (Uint8List.address → no copy) ─────►│ ├─ ReadHandler → binary fragment encoder + │ │ ├─ ControlHandler / OutstationApplication / + │◄──── Dart_PostCObject ───────────────│ │ OutstationInformation / AssociationInformation + │ [kind, id, sub, payload] │ │ → port messages, sync/async request-reply + │ payload: external typed data │ └─ tracing → package:logging + RawReceivePort per isolate │ +``` + +* **Handles are integers.** Every native object (channel, association, poll, outstation, + server) lives in a process-wide registry and is referenced by a 64-bit handle that can be + sent to any isolate and re-attached (`Association.attach(handle)`, `Outstation.attach`, + `Database(handle)`). +* **One `RawReceivePort` per isolate** receives completions, events and callback requests. + Async operations complete through it; events are routed to the owning Dart object. +* **Callbacks that DNP3 requires to be synchronous** (outstation SELECT/OPERATE, restart, + time write, freeze) are executed natively by default (`ControlMode.accept`, + `FreezeMode.auto`, ...). When configured as `callback`, the protocol worker posts a request + to Dart and parks itself with `block_in_place` (other sessions keep running) until Dart + replies or a configurable timeout expires. Replies can carry database operations which are + applied atomically before the response is sent. +* **Configuration** objects are serialized to JSON once (cold path); all hot paths + (measurements, database updates, commands, read requests) use compact little-endian binary + formats documented in [`doc/wire_format.md`](wire_format.md). + +## Isolates + +* `MasterChannel.addAssociation(..., eventPort: worker.port)` — measurement fragments are + posted by native code **directly to the worker isolate**; the creating (UI) isolate is not + involved at all. `MeasurementWorker.spawn(callback)` creates such a worker. +* `Database(outstationHandle)` works in any isolate: e.g. a sampling isolate can feed the + outstation database at high rate without touching the UI isolate. +* `Association.attach(handle)`, `MasterChannel.attach(handle)`, `Poll.attach(handle)`, + `Outstation.attach(handle)` give full API access from other isolates; completions are + delivered to the isolate that issued the call. +* `DatabaseTransaction` can be built anywhere and shipped as bytes + (`TransferableTypedData`) to be applied with `database.applyBytes(...)`. + +## Zero-copy in detail + +| Path | Mechanism | +|---|---| +| native → Dart measurements | one contiguous buffer per fragment; ≥ threshold it is posted as **external typed data** (the VM adopts the Rust allocation, freed by a finalizer). Dart reads it in place through `ByteData` flyweights. | +| Dart → native requests / database ops | encoded into a reusable Dart `Uint8List`; passed to `@Native(isLeaf: true)` functions via `Uint8List.address`, so Rust reads the Dart heap directly. | +| outstation range reads | native buffer exposed with `Pointer.asTypedList(finalizer:)`. | +| file blocks | external typed data views. | + +The external-vs-copy threshold defaults to **128 KiB**: the benchmarks below show that for +small buffers a copy into the Dart young generation is as cheap as the finalizer-based +transfer, while for large buffers zero-copy is up to 4× faster. Change it with +`Dnp3.zeroCopyThreshold = bytes` (`0` = always zero-copy). diff --git a/doc/benchmarks.md b/doc/benchmarks.md new file mode 100644 index 0000000..52bfafb --- /dev/null +++ b/doc/benchmarks.md @@ -0,0 +1,74 @@ +# Benchmarks + +`dart run benchmark/run_all.dart [--quick] [--out benchmark/results/.md]` + +Results below: Linux x64 VM, 4 vCPUs, Dart 3.13.5, release build with LTO +([`benchmark/results/linux-x64.md`](../benchmark/results/linux-x64.md)). CI runs the +same suite on Linux x64/arm64, macOS and Windows and publishes the tables in the job summary. + +## 1. Native → Dart delivery of measurement fragments + +| points / fragment | transfer | bytes / fragment | fragments / s | points / s | MB / s | µs / fragment | +|---|---|---|---|---|---|---| +| 10 | zero-copy (external typed data) | 272 | 161.6 k | 1.62 M | 44.0 | 6.19 | +| 10 | copied (kTypedData) | 272 | 150.3 k | 1.50 M | 40.9 | 6.65 | +| 100 | zero-copy (external typed data) | 2432 | 97.7 k | 9.77 M | 237.7 | 10.23 | +| 100 | copied (kTypedData) | 2432 | 64.7 k | 6.47 M | 157.3 | 15.46 | +| 1000 | zero-copy (external typed data) | 24.0 k | 29.8 k | 29.77 M | 715.3 | 33.59 | +| 1000 | copied (kTypedData) | 24.0 k | 36.5 k | 36.50 M | 877.2 | 27.39 | +| 4000 | zero-copy (external typed data) | 96.0 k | 16.2 k | 64.62 M | 1551.3 | 61.91 | +| 4000 | copied (kTypedData) | 96.0 k | 16.3 k | 65.11 M | 1563.1 | 61.44 | +| 16000 | zero-copy (external typed data) | 384.0 k | 7058.9 | 112.94 M | 2710.8 | 141.66 | +| 16000 | copied (kTypedData) | 384.0 k | 1705.0 | 27.28 M | 654.8 | 586.50 | +| 1000 | zero-copy + decode every value | - | 47.4 k | 47.37 M | - | - | + +(“µs / fragment” includes native encoding, the port hop and Dart dispatch. Up to ~100 KB +both transfers are within noise of each other; at 384 KB zero-copy is 4× faster.) + +## 2. Decoding cost per point (1000 analog inputs per fragment) + +| method | ns / point | points / s | +|---|---|---| +| flyweight accessor: value only | 2.64 | 378.43 M | +| MeasurementVisitor: value only | 2.85 | 350.82 M | +| flyweight accessors: index + value + flags + time | 7.75 | 129.07 M | +| value objects (List), all fields | 31.36 | 31.89 M | +| baseline: JSON bytes → objects, all fields | 693.14 | 1.44 M | + +The binary flyweight path is **~90× faster** than a JSON based bridge (as used by typical +`MethodChannel` plugins) for the same information, and allocation-free. + +## 3. Outstation database transactions (analog updates with event detection) + +| updates / transaction | updates / s | ns / update | transactions / s | +|---|---|---|---| +| 1 | 9.23 M | 108.3 | 9.23 M | +| 10 | 31.50 M | 31.7 | 3.15 M | +| 100 | 34.78 M | 28.8 | 347.8 k | +| 1000 | 26.60 M | 37.6 | 26.6 k | +| read (getAnalogInput) | 5.96 M | - | - | + +## 4. End-to-end over TCP loopback (master and outstation in the same process) + +| operation | p50 µs | p99 µs | ops / s | +|---|---|---|---| +| integrity poll, 1000 analogs (g30v6), decoded | 536 | 1261 | 1738.2 | +| read 10 analogs (range) | 92 | 221 | 9946.9 | +| direct operate CROB (accept mode, native) | 112 | 310 | 8212.9 | +| select-before-operate CROB (accept mode) | 121 | 386 | 7103.1 | +| direct operate CROB (Dart callback round trip) | 137 | 370 | 6616.8 | +| select-before-operate CROB (2 Dart callbacks) | 243 | 721 | 3567.0 | + +A synchronous Dart control callback adds ~25 µs (p50) per SELECT/OPERATE. + +## 5. Event throughput outstation → master (unsolicited, TCP loopback) + +| events | ms | events / s | +|---|---|---| +| 200.0 k | 134 | 1.49 M | + +## 6. Delivery straight into a worker isolate (1000 points per fragment, decoded) + +| receiver | fragments / s | points / s | +|---|---|---| +| worker isolate | 69.1 k | 69.06 M | diff --git a/doc/master.md b/doc/master.md new file mode 100644 index 0000000..181a0c0 --- /dev/null +++ b/doc/master.md @@ -0,0 +1,123 @@ +# Master API + +A tour of the master side. Every member is documented in the +[API reference](https://pub.dev/documentation/dnp3/latest/). + +## Overview + +```dart +// channels +MasterChannel.tcpClient(config:, endpoints:, linkErrorMode:, connectStrategy:, connectOptions:) +MasterChannel.tlsClient(config:, endpoints:, tls: TlsConfig(...)) +MasterChannel.serial(config:, path: '/dev/ttyUSB0', settings: SerialSettings(baudRate: 9600)) +MasterChannel.udp(config:, localEndpoint: '0.0.0.0:20000') +channel.clientStates / portStates / connectionEvents // streams +channel.enable() / disable() / setDecodeLevel() / getDecodeLevel() / destroy() + +// associations +final a = await channel.addAssociation(1024, + config: AssociationConfig(autoTimeSync: TimeSyncProcedure.lan, keepAliveTimeout: ...), + udpDestination: '10.0.0.5:20000', // UDP channels only + information: true, // task start/success/fail, unsolicited events + eventPort: worker.port); // deliver to another isolate + +a.measurements // Stream (zero-copy) +a.information // Stream +await a.read(ReadRequest.integrity() | .events() | .classScan(Classes(...)) + | .range(Variation.group30Var5, 0, 9) | .headers([...]) | .deviceAttribute(v)); +await a.readWithHandler(request) // returns List +await a.operate(CommandSet()..addCrob(0, Crob(ControlCode.latchOn()))..addAnalog(1, AnalogOutput.float32(5)), + mode: CommandMode.selectBeforeOperate | directOperate); +await a.synchronizeTime(TimeSyncProcedure.nonLan); +await a.coldRestart() / warmRestart(); // → Duration reported by the outstation +await a.writeDeadBands([DeadBandHeader.g34v3({0: 1.5})]); +await a.freezeCounters(clear: true); +await a.enableUnsolicited(EventClasses.all()) / disableUnsolicited(...); +await a.writeAttribute(DeviceAttribute.string(AttributeVariation.userAssignedLocation, 'Kyiv')); +await a.sendAndExpectEmptyResponse(FunctionCode.freezeAtTime, + RequestHeaders()..timeAndInterval(Dnp3Time.now(), 60000)..allObjects(Variation.group20Var0)); +await a.checkLinkStatus(); +final poll = await a.addPoll(ReadRequest.events(), Duration(seconds: 1)); poll.demand(); poll.remove(); +a.setTimeOffset(Duration(hours: -1)); +a.setTimeSource(TimeSource.manual(gpsTime)); // or TimeSource.system(offset) / TimeSource.unavailable() + +// files +final key = await a.getFileAuthKey('user', 'pass'); +final f = await a.openFile('/log.txt', authKey: key, mode: FileMode.read); +a.readFile('/log.txt', onOpened: (size) => size < 1 << 20).blocks.listen(...); // streaming, abortable +await a.writeFile('/cfg.bin', bytes); // open + blocks + close +await a.readDirectory('/'); await a.getFileInfo('/x'); + +// reconnect logic in Dart (TCP / TLS masters and outstation clients) +MasterChannel.tcpClient(..., connectionPolicy: MyPolicy()); // next() → connect(endpoint) | wait(delay) + +// master as TCP server (outstations connect to us) +final server = await MasterServer.bind('0.0.0.0:20000', MyHandler()); // accept / start / acceptLinkId +``` + +## Measurements + +`MeasurementFragment` exposes the response header (`readType`, `iin`, `sequence`, `fir/fin/con/uns`) +and its object headers as typed `MeasurementBlock`s: `BinaryInputBlock`, +`DoubleBitBinaryInputBlock`, `BinaryOutputStatusBlock`, `CounterBlock`, `FrozenCounterBlock`, +`AnalogInputBlock`, `FrozenAnalogInputBlock`, `AnalogInputDeadBandBlock`, +`AnalogOutputStatusBlock`, `AnalogOutputCommandEventBlock`, `BinaryOutputCommandEventBlock`, +`UnsignedIntegerBlock`, `OctetStringBlock`, `DeviceAttributeBlock`, `AbsTimeBlock`. Each block +knows its variation, qualifier, `isEvent` and `hasFlags`, and is both a lazily decoded +`List` and a flyweight with `indexAt / valueAt / flagsAt / timeAt` accessors. A +`MeasurementVisitor` dispatches all blocks of a fragment by type. + +## Flow control (back-pressure) + +```dart +final a = await channel.addAssociation(1024, maxInFlightFragments: 8, manualAck: true); +a.measurements.listen((f) async { + await store(f); // slow consumer + a.acknowledge(); // releases one slot +}); +``` + +With `maxInFlightFragments > 0` the native read handler suspends after posting that many +unacknowledged fragments. The master then stops reading the channel, so TCP flow control +slows the outstation down instead of letting fragments pile up in the Dart heap. Without +`manualAck` every fragment is acknowledged right after the synchronous listeners ran; +`MeasurementWorker` acknowledges after its callback. `backpressureTimeout` (default 30 s) +bounds the wait if a consumer stops acknowledging. + +## Connection policy + +```dart +class FailoverPolicy extends ConnectionPolicy { + @override + ConnectDecision next(ConnectContext c) => c.failures < 3 + ? const ConnectDecision.connect('10.0.0.1:20000') + : const ConnectDecision.connect('10.0.0.2:20000', timeout: Duration(seconds: 2)); + + @override + Duration disconnected(String address, String? host) => const Duration(seconds: 5); +} +``` + +Every method may be async. If no answer arrives within `callbackTimeout` (default 1 s), or +a method returns null, the native round-robin policy with exponential back-off decides. +`ConnectDecision.connect` can also set the connect timeout, the local endpoint and, for +outstation clients, the master address of that connection. + +## Error details + +Every exception carries `detail` with the structured library error, e.g. +`Link(Stdio(ConnectionReset))`, `MalformedResponse(InsufficientBytes)`, +`RejectedByIin2(Iin { ... })`. + +## Logging & errors + +```dart +Dnp3.logger.onRecord.listen(print); // package:logging +Dnp3.enableLogging(level: Dnp3LogLevel.info); +channel.setDecodeLevel(const DecodeLevel(application: AppDecodeLevel.objectValues)); +``` + +Errors are typed: `TaskException(TaskError.responseTimeout, ...)`, `CommandException` +(`status` for rejected controls), `TimeSyncException`, `WriteException` (IIN2), +`FileException`, `AssociationException`, `ConfigException`, `ShutdownException`, +`InvalidHandleException`, `NativeException`. diff --git a/doc/outstation.md b/doc/outstation.md new file mode 100644 index 0000000..bc0acfa --- /dev/null +++ b/doc/outstation.md @@ -0,0 +1,46 @@ +# Outstation API + +A tour of the outstation side. Every member is documented in the +[API reference](https://pub.dev/documentation/dnp3/latest/). + +## Overview + +```dart +Outstation.tcpClient(..., masterAddress: 1, connectionPolicy: ...) / tlsClient(...) +Outstation.serial(path:, retry: RetryStrategy()) / Outstation.udp(...) +final server = OutstationServer.tcp('0.0.0.0:20000'); // or .tls(address, TlsConfig) +final o = server.addOutstation(config: ..., handlers: ..., filter: AddressFilter.wildcard('10.0.0.*')); +await server.bind(); + +o.transaction((tx) { // one native call, one lock, one change notification + tx.addCounter(0, eventClass: EventClass.class2, staticVariation: StaticCounterVariation.group20Var5); + tx.updateAnalogInput(3, 1.5, flags: Flags.online, time: Dnp3Time.now(), mode: EventMode.force); + tx.updateFlags(PointType.analogInput, 3, Flags.commLost); + tx.defineAttribute(DeviceAttribute.string(AttributeVariation.deviceManufacturerName, 'ACME')); + tx.removeBinaryInput(7); +}); +o.database.transactionWithResults(...) // per-op UpdateResult (event ids, overflow) +o.database.getAnalogInput(3) // point reads; getRange(PointType.counter, 0, 99) → zero-copy block +o.setApplicationIin(needTime: true, deviceTrouble: false); +o.events // ControlExecuted, TimeWritten, DeadBandsWritten, AttributeWritten, CountersFrozen, RestartRequested, EventsConfirmed +o.information // OutstationInformation callbacks +o.connectionStates / clientStates / portStates / connectionEvents +``` + +## Handlers + +Handler configuration (`OutstationHandlers`): + +| Concern | Native modes | Dart callback | +|---|---|---| +| SELECT / OPERATE | `ControlMode.accept` (mirror into BOS / AOS, notify), `ControlMode.reject` + `rejectStatus` | `ControlMode.callback` + `onControl: (req) => ControlResponse(status, tx)` | +| cold / warm restart | `RestartMode.notSupported`, `seconds` / `milliseconds` + `coldRestartDelay` / `warmRestartDelay` | `RestartMode.callback` → `OutstationApplication.coldRestart()` | +| time write | `notSupported`, `accept` (+ event) | `TimeWriteMode.callback` | +| counter freeze | `notSupported`, `auto` (counter → frozen counter, freeze & clear, and FREEZE_AT_TIME: once, periodic, periodic relative to the hour — scheduled natively) | `FreezeMode.callback` → `(result, tx)` | +| request with controls | in `accept` mode all mirrored values are written in one transaction | `onControlFragmentBegin` / `onControlFragmentEnd` → `DatabaseTransaction` applied before the response | +| attribute write | `accept` (+ event), `reject` | `AttributeWriteMode.callback` (async) | +| dead band writes / confirms | notifications | — | + +Callback mode parks one protocol worker per pending request. Do not `await` operations on +the *same* outstation (enable/disable/decode level) inside a control callback: they are +queued behind the callback and only run after it returns (or times out). diff --git a/example/README.md b/example/README.md new file mode 100644 index 0000000..ab6f01f --- /dev/null +++ b/example/README.md @@ -0,0 +1,25 @@ +# Examples + +| File | What it shows | +|---|---| +| [`example.dart`](example.dart) | Outstation and master in one process over TCP loopback: read, zero-copy decode, control. Runs to completion. | +| [`outstation.dart`](outstation.dart) | TCP outstation with points that change every second, native control handling, logging. | +| [`master.dart`](master.dart) | Interactive TCP master: event and integrity polls, CROB / analog commands, device attributes. | +| [`worker_isolate.dart`](worker_isolate.dart) | Measurements posted by native code straight into a worker isolate. | +| [`flutter_app/`](flutter_app) | Flutter UI: in-app outstation (demo mode) or a master connected to any device. | + +```sh +dart run example/example.dart + +# two terminals +dart run example/outstation.dart 127.0.0.1:20000 +dart run example/master.dart 127.0.0.1:20000 # then type: on 1 | off 1 | ao 2 12.5 | integrity | attrs | quit + +dart run example/worker_isolate.dart 127.0.0.1:20000 +``` + +To ship a CLI tool, `dart build cli -t example/master.dart` produces a self-contained bundle +with the native library next to the executable. + +The first run compiles the native library (Rust toolchain required, see the +[package README](../README.md#installation)); later runs reuse it. diff --git a/example/example.dart b/example/example.dart new file mode 100644 index 0000000..2b971c7 --- /dev/null +++ b/example/example.dart @@ -0,0 +1,57 @@ +// Self-contained tour of package:dnp3: an outstation and a master in the same process, +// talking DNP3 over TCP loopback. Runs to completion, no external device needed. +// +// dart run example/example.dart +// +// See also master.dart, outstation.dart and worker_isolate.dart in this folder. +import 'package:dnp3/dnp3.dart'; + +Future main() async { + // ---------------------------------------------------------------- outstation + final server = OutstationServer.tcp('127.0.0.1:0'); // port 0: pick a free port + final outstation = server.addOutstation( + config: const OutstationConfig(outstationAddress: 1024, masterAddress: 1), + // SELECT / OPERATE are executed natively and mirrored into the output status points + handlers: const OutstationHandlers( + control: ControlHandlerConfig(mode: ControlMode.accept, updateDatabase: true), + ), + ); + + // One transaction = one native call and one lock, however many points it touches + outstation.transaction((tx) { + for (var i = 0; i < 5; i++) { + tx + ..addAnalogInput(i, staticVariation: StaticAnalogInputVariation.group30Var6) + ..addBinaryOutputStatus(i) + ..updateAnalogInput(i, 20.0 + i, time: Dnp3Time.now()); + } + }); + final address = await server.bind(); + print('outstation listening on $address'); + + // -------------------------------------------------------------------- master + final channel = MasterChannel.tcpClient( + config: const MasterChannelConfig(masterAddress: 1), + endpoints: [address], + ); + final association = await channel.addAssociation(1024); + await channel.enable(); + + // Responses are delivered as zero-copy fragments: values are read straight out of the + // native buffer through flyweight accessors. + final fragments = await association.readWithHandler(const ReadRequest.classScan(Classes.class0())); + for (final block in fragments.expand((f) => f.blocks).whereType()) { + for (var i = 0; i < block.count; i++) { + print('AI[${block.indexAt(i)}] = ${block.valueAt(i)}'); + } + } + + // Control: latch on output 2; the outstation mirrors it into BinaryOutputStatus 2 + await association.operate(CommandSet.crob(2, const Crob.latchOn())); + print('BOS[2] after LATCH_ON: ${outstation.database.getBinaryOutputStatus(2)?.value}'); + + // -------------------------------------------------------------------- cleanup + channel.destroy(); + server.destroy(); + Dnp3.shutdown(); +} From 7b7a2184d9785a2066900d73ed538a8066ab528c Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 12 Oct 2026 22:51:21 +0000 Subject: [PATCH 2/2] docs: document inherited block accessors; formatter page width 110 pub.dev scored 0.3.0 at 150/160: no example (added in the previous commit) and undocumented accessors inherited from the private block base classes. Also declare the 110 column page width in analysis_options so pana's format check matches the repository style. Local pana: 160/160. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01AVXyRT6zJUzzD6oSDcfG9W --- CHANGELOG.md | 3 +++ analysis_options.yaml | 3 +++ lib/src/master.dart | 5 ++++- lib/src/measurements.dart | 31 +++++++++++++++++++++++++++++++ lib/src/requests.dart | 3 +++ lib/src/types.dart | 5 ++++- 6 files changed, 48 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3eadb6d..6ebf1c8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,9 @@ contain breaking changes, which are marked **Breaking**. New entries go under examples table); the detailed guides moved to `doc/master.md`, `doc/outstation.md`, `doc/architecture.md` and `doc/benchmarks.md`. * New `example/example.dart`: a self-contained outstation + master demo. +* Documented the flyweight accessors (`valueAt`, `flagsAt`, `timeAt`, ...) inherited by the + measurement blocks; `analysis_options.yaml` sets the formatter page width to 110 so + `dart format` and pub.dev's analysis agree. * Community files: `CONTRIBUTING.md`, `CODE_OF_CONDUCT.md`, `SECURITY.md`, issue and pull request templates. diff --git a/analysis_options.yaml b/analysis_options.yaml index 80b43ab..fa623f7 100644 --- a/analysis_options.yaml +++ b/analysis_options.yaml @@ -9,3 +9,6 @@ analyzer: linter: rules: - public_member_api_docs + +formatter: + page_width: 110 diff --git a/lib/src/master.dart b/lib/src/master.dart index 3dc8bad..b1d0ce4 100644 --- a/lib/src/master.dart +++ b/lib/src/master.dart @@ -475,7 +475,10 @@ final class AssociationEvent { } /// Permissions of a DNP3 file (owner / group / world × read / write / execute) -extension type const FilePermissions(int bits) { +extension type const FilePermissions( + /// Raw 9-bit permission mask (owner rwx in bits 8..6, group 5..3, world 2..0) + int bits +) { /// No permission static const FilePermissions none = FilePermissions(0); diff --git a/lib/src/measurements.dart b/lib/src/measurements.dart index b400234..7390978 100644 --- a/lib/src/measurements.dart +++ b/lib/src/measurements.dart @@ -736,6 +736,9 @@ final class MeasurementFragment { /// Callback interface for [MeasurementFragment.visit]. Override what you need. abstract class MeasurementVisitor { + /// Const constructor for subclasses. + const MeasurementVisitor(); + /// Called for each binary input block (g1/g2); does nothing by default. void binaryInputs(BinaryInputBlock block) {} @@ -946,9 +949,17 @@ sealed class _BinaryLike extends _FixedBlock { _BinaryLike(ByteData d, int h) : super(d, h, 16); int _rawValueAt(int i) => _d.getUint8(_p + i * 16 + 2); + + /// Quality flags of record [i] (all-zero flags when the variation carries none). Flags flagsAt(int i) => Flags(_d.getUint8(_p + i * 16 + 3)); + + /// Time kind of record [i]: 0 no time, 1 synchronized, 2 unsynchronized. int timeKindAt(int i) => _d.getUint8(_p + i * 16 + 4); + + /// Raw timestamp of record [i] in ms since the Unix epoch (0 when it carries no time). int timeMillisAt(int i) => _d.getUint64(_p + i * 16 + 8, _le); + + /// Timestamp of record [i], or null when the variation carries no time. Dnp3Time? timeAt(int i) => Dnp3Time.decode(timeKindAt(i), timeMillisAt(i)); } @@ -991,10 +1002,20 @@ final class BinaryOutputStatusBlock extends _BinaryLike { /// `u16 index, u8 flags, u8 tk, u32 value, u64 time` sealed class _CounterLike extends _FixedBlock { _CounterLike(ByteData d, int h) : super(d, h, 16); + + /// Value of record [i], read without allocating. int valueAt(int i) => _d.getUint32(_p + i * 16 + 4, _le); + + /// Quality flags of record [i] (all-zero flags when the variation carries none). Flags flagsAt(int i) => Flags(_d.getUint8(_p + i * 16 + 2)); + + /// Time kind of record [i]: 0 no time, 1 synchronized, 2 unsynchronized. int timeKindAt(int i) => _d.getUint8(_p + i * 16 + 3); + + /// Raw timestamp of record [i] in ms since the Unix epoch (0 when it carries no time). int timeMillisAt(int i) => _d.getUint64(_p + i * 16 + 8, _le); + + /// Timestamp of record [i], or null when the variation carries no time. Dnp3Time? timeAt(int i) => Dnp3Time.decode(timeKindAt(i), timeMillisAt(i)); } @@ -1019,10 +1040,20 @@ final class FrozenCounterBlock extends _CounterLike { /// `u16 index, u8 flags, u8 tk, u32 pad, f64 value, u64 time` sealed class _AnalogLike extends _FixedBlock { _AnalogLike(ByteData d, int h) : super(d, h, 24); + + /// Value of record [i], read without allocating. double valueAt(int i) => _d.getFloat64(_p + i * 24 + 8, _le); + + /// Quality flags of record [i] (all-zero flags when the variation carries none). Flags flagsAt(int i) => Flags(_d.getUint8(_p + i * 24 + 2)); + + /// Time kind of record [i]: 0 no time, 1 synchronized, 2 unsynchronized. int timeKindAt(int i) => _d.getUint8(_p + i * 24 + 3); + + /// Raw timestamp of record [i] in ms since the Unix epoch (0 when it carries no time). int timeMillisAt(int i) => _d.getUint64(_p + i * 24 + 16, _le); + + /// Timestamp of record [i], or null when the variation carries no time. Dnp3Time? timeAt(int i) => Dnp3Time.decode(timeKindAt(i), timeMillisAt(i)); } diff --git a/lib/src/requests.dart b/lib/src/requests.dart index 18c8e6b..3b3e3ac 100644 --- a/lib/src/requests.dart +++ b/lib/src/requests.dart @@ -363,6 +363,9 @@ final class _CommandHeader { final class RequestHeaders { final ByteWriter _w = ByteWriter(64); + /// Empty header list; add headers with the builder methods below. + RequestHeaders(); + /// Adds an all-objects header (qualifier 0x06) RequestHeaders allObjects(Variation v) => _read(ReadHeader.allObjects(v)); diff --git a/lib/src/types.dart b/lib/src/types.dart index e4c4f55..100de33 100644 --- a/lib/src/types.dart +++ b/lib/src/types.dart @@ -56,7 +56,10 @@ final class Dnp3Time { } /// Measurement quality flags (bit field) -extension type const Flags(int value) { +extension type const Flags( + /// The raw flag byte (bit 0 = ONLINE ... bit 7 = point-type specific) + int value +) { /// No flag set (0x00); note that this means OFFLINE. static const Flags none = Flags(0);