A Rust implementation of TASE.2 / ICCP (IEC 60870-6) — the inter-control- centre protocol used between SCADA / EMS control centres — together with a reusable, sans-I/O MMS + OSI upper-layer stack it is built on.
telcon-tase2 is the workspace/repository name; it publishes the mms-proto,
tase2-proto, and tase2 crates (the protocol crate is still named tase2).
0.1.0-dev. TASE.2 Blocks 1 (data values, datasets, naming), 2 (DS transfer sets and condition-monitoring reports) and 5 (device control with select-before-operate) are implemented and exercised by in-process loopback tests and an end-to-end shell-spec suite. Plain TCP and TLS (incl. mTLS with SHA-256 fingerprint pinning) transports are supported.
| Feature | State |
|---|---|
| Reusable sans-I/O MMS stack (BER, TPKT, COTP, Session, Presentation, ACSE, MMS) | ✅ |
| MMS association state machine (handshake, release, abort, timers) | ✅ |
| Block 1 — points (15 kinds), Read/Write, GetNameList, Define/Delete NVL | ✅ |
| Block 2 — DS transfer sets, condition monitoring, reports | ✅ |
| Block 5 — device control, direct + select-before-operate, tags | ✅ |
| Bilateral-table access control (default-deny) | ✅ |
Async Tase2Client / Tase2Server on tokio |
✅ |
TLS / mTLS via tokio-rustls (feature tls) |
✅ |
| End-to-end controllable daemons + shell-spec suite + Claude skills | ✅ |
| Critical/ACK transfer sets, BlockData transfer | deferred |
| Operator-request / external-event conditions | deferred |
| TASE.2 Blocks 3, 4, 6, 7, 8, 9 | deferred |
| Crate | Description |
|---|---|
mms-proto |
Reusable on its own. Sans-I/O MMS + OSI upper-layer stack: BER codec, TPKT/COTP framing, ISO Session/Presentation/ACSE, MMS services, and an MmsConnection association state machine. No async, no sockets, no clocks — every state-machine entry point takes the current Instant explicitly. Has no TASE.2 knowledge, so it can back any MMS-based protocol (e.g. IEC 61850). #![forbid(unsafe_code)]. |
tase2-proto |
Sans-I/O TASE.2 domain layer over mms-proto: point values + quality, datasets, Block-2 transfer sets + the TransferSetScheduler, Block-5 device control, and bilateral tables. No I/O. |
tase2 |
Async client/server on tokio that drives the proto core over TCP/TLS. Public surface: Tase2Client, Tase2Server, ServeOptions, the DataProvider / ControlHandler traits, reporting helpers, the EventHandler observer, and the optional tls module. Re-exports mms-proto as tase2::mms and tase2-proto as tase2::proto. |
tase2-test-tools |
publish = false. Two long-running, JSON-controllable daemons (tase2-server, tase2-client) plus the e2e shell specs. Used by the conformance suite. |
The dependency direction is strict and one-way: mms-proto knows nothing about
TASE.2 or tokio; tase2-proto knows nothing about tokio; tase2 drives
both over a real transport.
Two runnable examples live under crates/tase2/examples/.
Bring up a server, then a client:
cargo run --example simple_server # terminal 1: binds, serves ICC1 points + DS1 reports
cargo run --example simple_client # terminal 2: reads P1, subscribes a transfer setuse std::net::Ipv4Addr;
use std::time::Duration;
use tase2::{
AssocConfig, DomainName, DsConditions, Identifier, ObjectName, PointKind, Tase2Client,
Transport, DEFAULT_PORT,
};
fn dn(item: &str) -> ObjectName {
ObjectName::DomainSpecific {
domain: Identifier::new("ICC1").unwrap(),
item: Identifier::new(item).unwrap(),
}
}
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let addr = (Ipv4Addr::LOCALHOST, DEFAULT_PORT).into();
let mut client = Tase2Client::connect(Transport::tcp(addr), AssocConfig::default()).await?;
// Read one point.
let value = client.read_point(&dn("P1"), PointKind::Discrete).await?;
println!("P1 = {value:?}");
// Claim, configure and enable a DS transfer set, then collect a report.
let domain = DomainName::from(Identifier::new("ICC1")?);
let ts_name = client.next_transfer_set(&domain).await?;
let mut ts = client.read_transfer_set(&ts_name).await?;
ts.data_set_name = Some(tase2::TsDataSetName {
domain: Identifier::new("ICC1")?,
name: Identifier::new("DS1")?,
});
ts.interval = 1;
ts.conditions = DsConditions::INTERVAL_TIMEOUT | DsConditions::OBJECT_CHANGE;
ts.status = true;
client.write_transfer_set(&ts_name, &ts).await?;
let members = vec![
dn(tase2::names::TRANSFER_SET_NAME),
dn(tase2::names::TRANSFER_SET_TIME_STAMP),
dn(tase2::names::DSCONDITIONS_DETECTED),
dn(tase2::names::EVENT_CODE_DETECTED),
dn("P1"),
dn("P2"),
];
if let Ok(Some(pdu)) =
tokio::time::timeout(Duration::from_secs(5), client.recv_unsolicited()).await
{
let report = client.decode_report(&pdu, &members)?;
println!("report conditions={:?} values={:?}", report.conditions, report.values);
}
client.release().await?;
Ok(())
}use std::net::Ipv4Addr;
use std::sync::Arc;
use std::time::Duration;
use tase2::{
serve_connection_with_reports_interval, AssocConfig, DataSet, Identifier, MapDataProvider,
ObjectName, PointValue, Tase2Server, TransferSetManager, DEFAULT_PORT,
};
fn dn(item: &str) -> ObjectName {
ObjectName::DomainSpecific {
domain: Identifier::new("ICC1").unwrap(),
item: Identifier::new(item).unwrap(),
}
}
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let provider = Arc::new(MapDataProvider::new());
provider.insert_point(dn("P1"), PointValue::Discrete(10)).await;
provider.insert_point(dn("P2"), PointValue::Discrete(20)).await;
provider
.insert_data_set(DataSet::with_object_name(
dn("DS1"),
vec![
dn(tase2::names::TRANSFER_SET_NAME),
dn(tase2::names::TRANSFER_SET_TIME_STAMP),
dn(tase2::names::DSCONDITIONS_DETECTED),
dn(tase2::names::EVENT_CODE_DETECTED),
dn("P1"),
dn("P2"),
],
))
.await;
let bind = (Ipv4Addr::UNSPECIFIED, DEFAULT_PORT).into();
let server = Tase2Server::bind(bind, AssocConfig::default()).await?;
let mut conn = server.accept().await?;
let manager = Arc::new(TransferSetManager::new(provider.clone()));
serve_connection_with_reports_interval(&mut conn, provider, manager, Duration::from_millis(250))
.await?;
Ok(())
}A real server implements the DataProvider trait
over its own process image instead of using MapDataProvider, optionally adds a
ControlHandler for Block-5 control, and gates
access with a BilateralTable via
ServeOptions.
mms-proto is a standalone, sans-I/O MMS / OSI upper-layer stack with no TASE.2
dependency. If you need to speak MMS for a different application (e.g. an
IEC 61850 client), depend on it directly and drive MmsConnection over your own
transport — tase2 is just one consumer of it.
Enable the tls feature for tokio-rustls transport, including mutual TLS and
SHA-256 certificate-fingerprint pinning (common for self-signed substation
deployments). See the tls module re-exports in
crates/tase2/src/lib.rs and the mTLS loopback test
crates/tase2/tests/loopback_mtls.rs.
The tase2-test-tools crate builds two long-running daemons controllable at
runtime over a JSON-over-Unix-socket protocol — tase2-server (the outstation)
and tase2-client (the master) — plus short-lived CLI subcommands so a shell
script or an agent can drive a live conversation step by step.
On top of them sit 12 reproducible shell specs and an orchestrator:
cargo build -p tase2-test-tools --bins # required first
bash crates/tase2-test-tools/tests/specs/run_all.sh # whole suite
bash crates/tase2-test-tools/tests/specs/test_01_smoke.sh # one specSPECS.md in that directory documents each spec (including negative tests where a
failure response is the pass condition). Three in-repo Claude skills —
tase2-server, tase2-client, tase2-e2e-tests (under .claude/skills/) —
drive the daemons and the suite.
The codebase follows the sans-I/O pattern (as in quinn-proto / h2): the
protocol logic — codecs and the association state machine — lives in mms-proto
and tase2-proto with zero dependency on tokio, sockets, or wall-clock time.
Every state-machine entry point accepts the current Instant explicitly; the
async tase2 crate drives that state machine over a real transport. This makes
timeouts deterministically testable, lets one state machine serve TCP and TLS
unchanged, and keeps the protocol core reusable. See
docs/protocol-notes.md for byte-level reference and
HANDOVER.md for the architecture rationale.
cargo test --workspace --all-featuresThe library crates are exercised by unit tests, proptest round-trip tests over
the codec layer, in-process loopback integration tests per transport, and the
shell-spec suite. cargo fuzz targets under fuzz/ feed arbitrary bytes
to the decoders and opcode streams to the state machine. Both library crates are
#![forbid(unsafe_code)]; all parsing is bounds-checked and returns typed errors
rather than panicking on malformed or hostile input. CI enforces ≥ 80 % line
coverage on the publishable crates.
Rust 1.83, pinned in clippy.toml and tested in CI.
Dual-licensed under either of
- Apache License, Version 2.0 (LICENSE-APACHE)
- MIT license (LICENSE-MIT)
at your option.