OutstationApplication interface
OutstationApplication is how the outstation asks your application for what only it knows, and tells it about changes
worth saving. Each callback covers one job:
| Job | Called when | What your code does |
|---|---|---|
| IIN bits | The outstation builds a response | Reports the state of your device |
| Time synchronization | The master writes the time | Sets the clock, if it accepts the master's time |
| Restarts | The master asks for a cold or warm restart | Usually reports that restarts aren't supported |
| Counter freezes | The master freezes counters | Copies counters to frozen counters |
| Saving changes | The master writes deadbands, event classes or device attributes | Saves them so they survive a restart |
| Event confirmations | The master confirms events | Tracks which events were delivered, if it needs to |
IIN bits
The outstation calls getApplicationIin() for every response and merges your bits with the ones it manages itself. You
control four:
needTime: the outstation needs time synchronizationlocalControl: some output points are in local modedeviceTrouble: the device has a problemconfigCorrupt: the device configuration is corrupt
Time synchronization
Whichever procedure the master uses, the outstation calls writeAbsoluteTime(..) with the time in milliseconds since
January 1st, 1970, 00:00:00 UTC.
Set needTime when your clock needs synchronizing, typically at an interval that depends on its accuracy, and clear it
in writeAbsoluteTime(..). If the bit is still set in the response, the master assumes the synchronization failed, and
some masters then write the time over and over.
An outstation that gets its time from another source, such as NTP or IRIG-B, usually rejects the master's time unless that
source is unavailable. One that doesn't support time synchronization at all returns NOT_SUPPORTED.
The example outstations ask for the time when they start, and clear the request when the master writes it:
- Rust
- C
- C++
- Java
- C#
fn get_application_iin(&self) -> ApplicationIin {
ApplicationIin {
need_time: self.need_time,
..Default::default()
}
}
fn write_absolute_time(&mut self, time: Timestamp) -> Result<(), RequestError> {
// a real device sets its clock here, and sets need_time again when its clock drifts
tracing::info!("write absolute time: {time}");
self.need_time = false;
Ok(())
}
dnp3_application_iin_t get_application_iin(void *context)
{
dnp3_application_iin_t iin = dnp3_application_iin_init();
iin.need_time = ((outstation_application_t *)context)->need_time;
return iin;
}
dnp3_write_time_result_t write_absolute_time(uint64_t time, void *context)
{
// a real device sets its clock here, and sets need_time again when its clock drifts
printf("write absolute time: %" PRIu64 "\n", time);
((outstation_application_t *)context)->need_time = false;
return DNP3_WRITE_TIME_RESULT_OK;
}
ApplicationIin get_application_iin() override
{
ApplicationIin iin;
iin.need_time = need_time;
return iin;
}
WriteTimeResult write_absolute_time(uint64_t time) override
{
// a real device sets its clock here, and sets need_time again when its clock drifts
std::cout << "write absolute time: " << time << std::endl;
need_time = false;
return WriteTimeResult::ok;
}
@Override
public ApplicationIin getApplicationIin() {
return new ApplicationIin().withNeedTime(needTime);
}
@Override
public WriteTimeResult writeAbsoluteTime(ULong time) {
// a real device sets its clock here, and sets needTime again when its clock drifts
System.out.println("write absolute time: " + time);
needTime = false;
return WriteTimeResult.OK;
}
// ask the master for the time as soon as it connects
private bool needTime = true;
public ApplicationIin GetApplicationIin()
{
return new ApplicationIin { NeedTime = needTime };
}
public WriteTimeResult WriteAbsoluteTime(ulong time)
{
// a real device sets its clock here, and sets NeedTime again when its clock drifts
Console.WriteLine($"write absolute time: {time}");
needTime = false;
return WriteTimeResult.Ok;
}
Restarts
A master can ask the outstation to restart. Because an attacker could use this to take a device offline, we recommend
returning not supported from coldRestart and warmRestart in most products, and always in software gateways that share
a server with other services. If you must support restarts, for conformance tests or a customer requirement, return an
estimate of how long the restart takes; masters make little use of it, so it needn't be accurate. The outstation never
restarts on its own: your application does.
Counter freezes
When the master freezes counters, use the DatabaseHandle you're given to copy each counter to its frozen counter, and with
FREEZE_AND_CLEAR, also reset the counter to 0. Requests to freeze at a given time and interval are passed to your
application too.
The example outstations support both kinds of immediate freeze, but not freezing at a given time:
- Rust
- C
- C++
- Java
- C#
fn freeze_counter(
&mut self,
indices: FreezeIndices,
freeze_type: FreezeType,
database: &mut DatabaseHandle,
) -> Result<(), RequestError> {
let clear = match freeze_type {
FreezeType::ImmediateFreeze => false,
FreezeType::FreezeAndClear => true,
// freezing at a time and interval isn't supported
_ => return Err(RequestError::NotSupported),
};
let (start, stop) = match indices {
FreezeIndices::All => (0, POINT_COUNT - 1),
FreezeIndices::Range(start, stop) => (start, stop.min(POINT_COUNT - 1)),
};
tracing::info!("freeze counters {start} to {stop} (clear: {clear})");
database.transaction(|db| {
for index in start..=stop {
if let Some(counter) = Get::<Counter>::get(db, index) {
let time = get_current_time();
let frozen = FrozenCounter::new(counter.value, counter.flags, time);
db.update(index, &frozen, UpdateOptions::detect_event());
if clear {
let cleared = Counter::new(0, counter.flags, time);
db.update(index, &cleared, UpdateOptions::detect_event());
}
}
}
});
Ok(())
}
typedef struct freeze_request_t {
uint16_t start;
uint16_t stop;
bool clear;
} freeze_request_t;
void freeze_transaction(dnp3_database_t *db, void *context)
{
freeze_request_t *request = (freeze_request_t *)context;
for (uint32_t index = request->start; index <= request->stop; ++index) {
dnp3_counter_t counter;
if (dnp3_database_get_counter(db, (uint16_t)index, &counter) == DNP3_PARAM_ERROR_OK) {
dnp3_timestamp_t time = now();
dnp3_frozen_counter_t frozen = dnp3_frozen_counter_init(counter.index, counter.value, counter.flags, time);
dnp3_database_update_frozen_counter(db, frozen, dnp3_update_options_detect_event());
if (request->clear) {
dnp3_counter_t cleared = dnp3_counter_init(counter.index, 0, counter.flags, time);
dnp3_database_update_counter(db, cleared, dnp3_update_options_detect_event());
}
}
}
}
// freezing at a time and interval isn't supported: those callbacks keep their default
dnp3_freeze_result_t freeze_counters(uint16_t start, uint16_t stop, dnp3_freeze_type_t freeze_type, dnp3_database_handle_t *database)
{
freeze_request_t request = {
.start = start,
.stop = (stop < POINT_COUNT - 1) ? stop : POINT_COUNT - 1,
.clear = freeze_type == DNP3_FREEZE_TYPE_FREEZE_AND_CLEAR,
};
printf("freeze counters %u to %u (clear: %s)\n", request.start, request.stop, request.clear ? "true" : "false");
dnp3_database_transaction_t transaction = {
.execute = &freeze_transaction,
.on_destroy = NULL,
.ctx = &request,
};
dnp3_database_handle_transaction(database, transaction);
return DNP3_FREEZE_RESULT_OK;
}
dnp3_freeze_result_t freeze_counters_all(dnp3_freeze_type_t freeze_type, dnp3_database_handle_t *database, void *context)
{
return freeze_counters(0, POINT_COUNT - 1, freeze_type, database);
}
dnp3_freeze_result_t freeze_counters_range(uint16_t start, uint16_t stop, dnp3_freeze_type_t freeze_type, dnp3_database_handle_t *database, void *context)
{
return freeze_counters(start, stop, freeze_type, database);
}
FreezeResult freeze_counters_all(FreezeType freeze_type, DatabaseHandle &database) override
{
return freeze_counters(0, POINT_COUNT - 1, freeze_type, database);
}
FreezeResult freeze_counters_range(uint16_t start, uint16_t stop, FreezeType freeze_type, DatabaseHandle &database) override
{
return freeze_counters(start, stop, freeze_type, database);
}
ate:
// freezing at a time and interval isn't supported: those methods keep their default
FreezeResult freeze_counters(uint16_t start, uint16_t stop, FreezeType freeze_type, DatabaseHandle &database)
{
const uint16_t last = std::min<uint16_t>(stop, POINT_COUNT - 1);
const bool clear = freeze_type == FreezeType::freeze_and_clear;
std::cout << "freeze counters " << start << " to " << last << " (clear: " << (clear ? "true" : "false") << ")" << std::endl;
auto transaction = database_transaction([=](Database &db) {
for (uint32_t index = start; index <= last; ++index) {
try {
const auto counter = db.get_counter(static_cast<uint16_t>(index));
const auto time = now();
db.update_frozen_counter(FrozenCounter(counter.index, counter.value, counter.flags, time), UpdateOptions::detect_event());
if (clear) {
db.update_counter(Counter(counter.index, 0, counter.flags, time), UpdateOptions::detect_event());
}
}
catch (const ParamException &) {
// no counter at this index
}
}
});
database.transaction(transaction);
return FreezeResult::ok;
}
@Override
public FreezeResult freezeCountersAll(FreezeType freezeType, DatabaseHandle database) {
return freezeCounters(0, OutstationExample.POINT_COUNT - 1, freezeType, database);
}
@Override
public FreezeResult freezeCountersRange(
UShort start, UShort stop, FreezeType freezeType, DatabaseHandle database) {
return freezeCounters(
start.intValue(), Math.min(stop.intValue(), OutstationExample.POINT_COUNT - 1), freezeType, database);
}
// freezing at a time and interval isn't supported: the default implementations reject it
private FreezeResult freezeCounters(int start, int stop, FreezeType freezeType, DatabaseHandle database) {
final boolean clear = freezeType == FreezeType.FREEZE_AND_CLEAR;
System.out.println("freeze counters " + start + " to " + stop + " (clear: " + clear + ")");
database.transaction(db -> {
for (int index = start; index <= stop; index++) {
// an exception must not escape the transaction, so skip a point that doesn't exist (yet)
try {
final Counter counter = db.getCounter(ushort(index));
final Timestamp time = OutstationExample.now();
db.updateFrozenCounter(new FrozenCounter(counter.index, counter.value, counter.flags, time), UpdateOptions.detectEvent());
if (clear) {
db.updateCounter(new Counter(counter.index, uint(0), counter.flags, time), UpdateOptions.detectEvent());
}
} catch (ParamException ex) {
// no counter at this index
}
}
});
return FreezeResult.OK;
}
public FreezeResult FreezeCountersAll(FreezeType freezeType, DatabaseHandle database)
{
return FreezeCounters(0, PointCount - 1, freezeType, database);
}
public FreezeResult FreezeCountersRange(ushort start, ushort stop, FreezeType freezeType, DatabaseHandle database)
{
return FreezeCounters(start, Math.Min((int)stop, PointCount - 1), freezeType, database);
}
// freezing at a time and interval isn't supported
public FreezeResult FreezeCountersAllAtTime(DatabaseHandle database, ulong time, uint interval)
{
return FreezeResult.NotSupported;
}
public FreezeResult FreezeCountersRangeAtTime(ushort start, ushort stop, DatabaseHandle database, ulong time, uint interval)
{
return FreezeResult.NotSupported;
}
private static FreezeResult FreezeCounters(int start, int stop, FreezeType freezeType, DatabaseHandle database)
{
var clear = freezeType == FreezeType.FreezeAndClear;
Console.WriteLine($"freeze counters {start} to {stop} (clear: {(clear ? "true" : "false")})");
database.Transaction(db =>
{
for (var i = start; i <= stop; i++)
{
var index = (ushort)i;
try
{
var counter = db.GetCounter(index);
var time = Now();
db.UpdateFrozenCounter(new FrozenCounter(index, counter.Value, counter.Flags, time), UpdateOptions.DetectEvent());
if (clear)
{
db.UpdateCounter(new Counter(index, 0, counter.Flags, time), UpdateOptions.DetectEvent());
}
}
catch (ParamException)
{
// skip a point the database doesn't have (yet): an exception must not escape the callback
}
}
});
return FreezeResult.Ok;
}
Saving what the master changes
A master can change three kinds of settings in the outstation. The outstation applies each change to its database itself; the callbacks let you save it to non-volatile memory and apply it again when you add your points at startup. An outstation that doesn't save them returns to its configured values when it restarts.
| Setting | Callbacks | Notes |
|---|---|---|
| Analog input deadbands | beginWriteAnalogDeadBands, one call per deadband, endWriteAnalogDeadBands | Rejected unless supportWriteAnalogDeadBands returns true |
| Event classes | beginAssignClass, one call per point header, endAssignClass | Processed unless supportAssignClass returns false |
| Device attributes | One call per attribute written | Only for attributes you define as writable |
Save each batch in its end callback. A new event class applies only to events created afterwards.
An attribute write callback can reject a value, for example one out of range, by returning false. The outstation itself rejects writes to attributes that aren't defined or writable, or whose type doesn't match.
The example outstations log each batch where a device would save it:
- Rust
- C
- C++
- Java
- C#
fn support_write_analog_dead_bands(&mut self) -> bool {
true
}
fn begin_write_analog_dead_bands(&mut self) {
tracing::info!("begin writing analog dead-bands");
}
fn write_analog_dead_band(&mut self, index: u16, dead_band: f64) {
tracing::info!("change analog dead-band {index} to {dead_band}");
}
fn end_write_analog_dead_bands(&mut self) -> MaybeAsync<()> {
tracing::info!("end writing analog dead-bands: save them here");
MaybeAsync::ready(())
}
fn begin_assign_class(&mut self) {
tracing::info!("begin assigning classes");
}
fn assign_class(
&mut self,
point_type: PointType,
indices: PointIndices,
class: Option<EventClass>,
) {
tracing::info!("assign class {class:?} to {point_type:?} {indices:?}");
}
fn end_assign_class(&mut self) -> MaybeAsync<()> {
tracing::info!("end assigning classes: save them here");
MaybeAsync::ready(())
}
bool support_write_analog_dead_bands(void *context) { return true; }
void begin_write_analog_dead_bands(void *context) { printf("begin writing analog dead-bands\n"); }
void write_analog_dead_band(uint16_t index, double dead_band, void *context)
{
printf("change analog dead-band %u to %.15g\n", index, dead_band);
}
void end_write_analog_dead_bands(void *context) { printf("end writing analog dead-bands: save them here\n"); }
void begin_assign_class(void *context) { printf("begin assigning classes\n"); }
void assign_class_all(dnp3_point_type_t point_type, dnp3_event_class_t point_class, void *context)
{
printf("assign class %s to all %s points\n", dnp3_event_class_to_string(point_class), dnp3_point_type_to_string(point_type));
}
void assign_class_range(dnp3_point_type_t point_type, uint16_t start, uint16_t stop, dnp3_event_class_t point_class, void *context)
{
printf("assign class %s to %s points %u to %u\n", dnp3_event_class_to_string(point_class), dnp3_point_type_to_string(point_type), start,
stop);
}
void end_assign_class(void *context) { printf("end assigning classes: save them here\n"); }
bool support_write_analog_dead_bands() override { return true; }
void begin_write_analog_dead_bands() override { std::cout << "begin writing analog dead-bands" << std::endl; }
void write_analog_dead_band(uint16_t index, double dead_band) override
{
std::cout << "change analog dead-band " << index << " to " << std::setprecision(15) << dead_band << std::endl;
}
void end_write_analog_dead_bands() override { std::cout << "end writing analog dead-bands: save them here" << std::endl; }
void begin_assign_class() override { std::cout << "begin assigning classes" << std::endl; }
void assign_class_all(PointType point_type, EventClass point_class) override
{
std::cout << "assign class " << to_string(point_class) << " to all " << to_string(point_type) << " points" << std::endl;
}
void assign_class_range(PointType point_type, uint16_t start, uint16_t stop, EventClass point_class) override
{
std::cout << "assign class " << to_string(point_class) << " to " << to_string(point_type) << " points " << start << " to " << stop
<< std::endl;
}
void end_assign_class() override { std::cout << "end assigning classes: save them here" << std::endl; }
@Override
public boolean supportWriteAnalogDeadBands() {
return true;
}
@Override
public void beginWriteAnalogDeadBands() {
System.out.println("begin writing analog dead-bands");
}
@Override
public void writeAnalogDeadBand(UShort index, double deadBand) {
System.out.println("change analog dead-band " + index + " to " + deadBand);
}
@Override
public void endWriteAnalogDeadBands() {
System.out.println("end writing analog dead-bands: save them here");
}
@Override
public void beginAssignClass() {
System.out.println("begin assigning classes");
}
@Override
public void assignClassAll(PointType pointType, EventClass pointClass) {
System.out.println("assign class " + pointClass + " to all " + pointType + " points");
}
@Override
public void assignClassRange(PointType pointType, UShort start, UShort stop, EventClass pointClass) {
System.out.println("assign class " + pointClass + " to " + pointType + " points " + start + " to " + stop);
}
@Override
public void endAssignClass() {
System.out.println("end assigning classes: save them here");
}
public bool SupportWriteAnalogDeadBands()
{
return true;
}
public void BeginWriteAnalogDeadBands()
{
Console.WriteLine("begin writing analog dead-bands");
}
public void WriteAnalogDeadBand(ushort index, double deadBand)
{
Console.WriteLine($"change analog dead-band {index} to {deadBand}");
}
public void EndWriteAnalogDeadBands()
{
Console.WriteLine("end writing analog dead-bands: save them here");
}
public bool SupportAssignClass()
{
return true;
}
public void BeginAssignClass()
{
Console.WriteLine("begin assigning classes");
}
public void AssignClassAll(PointType pointType, EventClass pointClass)
{
Console.WriteLine($"assign class {pointClass} to all {pointType} points");
}
public void AssignClassRange(PointType pointType, ushort start, ushort stop, EventClass pointClass)
{
Console.WriteLine($"assign class {pointClass} to {pointType} points {start} to {stop}");
}
public void EndAssignClass()
{
Console.WriteLine("end assigning classes: save them here");
}
The example master can WRITE the User Defined Location attribute of the example outstation.
Event confirmations
When the master confirms events, eventCleared reports each one delivered, by the ID its update returned. Most applications don't need
this; it helps when the source of the events wants to know which ones have been delivered.