TCP server
Some outstations, such as devices on cellular modems or behind NAT, connect to the master instead of waiting for it to
connect. A master TCP server accepts these connections and gives you a MasterChannel for each one. Your
ConnectionHandler decides which connections to accept and sets up each channel:
| Callback | Called when | What your code does |
|---|---|---|
accept | An outstation connects | Accepts or rejects it by remote address, or asks to identify it first |
acceptWithLinkId | The outstation's first link frame arrives | Accepts or rejects it by source and destination address |
start, startWithLinkId | The channel is created | Adds an association and enables the channel |
In Rust, acceptWithLinkId is named accept_link_id.
Creating the server
Create the server with the local address to listen on, a LinkIdConfig and your ConnectionHandler. Creating it fails
if the address can't be bound, for example because another process is listening on it.
- Rust
- C
- C++
- Java
- C#
let _server = spawn_master_tcp_server(
"127.0.0.1:20000".parse()?,
LinkIdConfig::default().decode_level(PhysDecodeLevel::Data),
ConnectionHandler {
channels: Default::default(),
},
)
.await?;
// There's no C example yet: call dnp3_create_master_tcp_server() with a dnp3_connection_handler_t
// There's no C++ example yet: call dnp3::MasterServer::create_tcp_server() with a dnp3::ConnectionHandler
The example also starts the thread that sets up each channel, as explained below.
final BlockingQueue<StartRequest> queue = new ArrayBlockingQueue<>(16);
final ConnectionHandler handler = new ConnectionHandler(queue);
try (MasterServer server = MasterServer.createTcpServer(runtime, "127.0.0.1:20000", new LinkIdConfig(), handler)) {
// the channels are started on another thread, so this one can read the console
final Thread starter = new Thread(() -> startChannels(queue));
starter.setDaemon(true);
starter.start();
// There's no C# example yet: call MasterServer.CreateTcpServer() with an IConnectionHandler
Accepting connections
The server calls accept with the remote address of each new connection. Your code can:
- accept it, with a
LinkErrorModeand a channel configuration. The server then callsstart. - ask the server to identify the outstation first.
- reject it, and the server closes the connection.
In the bindings, accept receives an AcceptHandler: call its accept(..) or getLinkIdentity() method, or neither to
reject the connection. In Rust, accept returns an AcceptAction, or Err(Reject).
Accepting by remote address works when each outstation connects from an address you know in advance.
Identifying the outstation
Outstations on cellular networks or behind NAT often don't have a fixed or unique address. Instead, the server can wait
for the outstation's first link frame, usually an unsolicited response, and call acceptWithLinkId with its source and
destination. The source is the outstation's DNP3 address, and the destination is the master address the outstation
expects, which you can use as the channel's master address.
You accept or reject the connection the same way, and the server then calls startWithLinkId. The frame isn't lost: the
new channel processes it. LinkIdConfig sets how long the server waits for the frame, after which it closes the
connection, and how many connections can wait at once.
- Rust
- C
- C++
- Java
- C#
impl dnp3::tcp::ConnectionHandler for ConnectionHandler {
async fn accept(&mut self, _: SocketAddr) -> Result<AcceptAction, Reject> {
Ok(AcceptAction::GetLinkIdentity)
}
async fn start(&mut self, _: MasterChannel, _: SocketAddr) {
//
}
async fn accept_link_id(
&mut self,
addr: SocketAddr,
source: u16,
_destination: u16,
) -> Result<AcceptConfig, Reject> {
tracing::info!("accepted from {addr:?}:{source}");
let mut decode_level = DecodeLevel::nothing();
decode_level.application = AppDecodeLevel::ObjectValues;
let config = AcceptConfig {
error_mode: LinkErrorMode::Close,
config: MasterChannelConfig {
master_address: EndpointAddress::try_new(1).unwrap(),
decode_level,
tx_buffer_size: Default::default(),
rx_buffer_size: Default::default(),
},
};
Ok(config)
}
async fn start_with_link_id(
&mut self,
mut channel: MasterChannel,
_addr: SocketAddr,
source: u16,
destination: u16,
) {
tracing::info!("start with source = {source} dest = {destination}");
match Self::setup_channel(&mut channel, source).await {
Ok(_) => {
self.channels.insert(source, channel);
}
Err(err) => {
tracing::warn!("channel setup failed: {err}");
}
}
}
}
// There's no C example yet: implement the callbacks of dnp3_connection_handler_t
// There's no C++ example yet: implement dnp3::ConnectionHandler
class ConnectionHandler implements io.stepfunc.dnp3.ConnectionHandler {
final BlockingQueue<StartRequest> queue;
ConnectionHandler(BlockingQueue<StartRequest> queue) {
this.queue = queue;
}
@Override
public void accept(String remoteAddr, AcceptHandler acceptor) {
acceptor.getLinkIdentity();
}
@Override
public void start(String remoteAddr, MasterChannel channel) {
// this should not be called since we always ask for the link identity
channel.shutdown();
}
@Override
public void acceptWithLinkId(String remoteAddr, UShort source, UShort destination, IdentifiedLinkHandler acceptor) {
MasterChannelConfig config = new MasterChannelConfig(destination);
config.decodeLevel.application = AppDecodeLevel.OBJECT_VALUES;
acceptor.accept(LinkErrorMode.CLOSE, config);
}
@Override
public void startWithLinkId(String remoteAddr, UShort source, UShort destination, MasterChannel channel) {
// We defer the handling of connections to the starter thread...
// We can't call any of the methods on channel like addAssociation b/c this callback is from Tokio
// Never block this callback: if the queue is full, drop the connection
if (!this.queue.offer(new StartRequest(channel, destination, source))) {
channel.shutdown();
}
}
}
// There's no C# example yet: implement IConnectionHandler
Starting the channel
start and startWithLinkId hand you the new MasterChannel. Like any channel, it doesn't communicate until it's
enabled: add an association for the outstation, then call enable.
In Rust, these callbacks are async, so you can do this directly in them. In the bindings, adding an association and enabling a channel can't be called from a callback, so pass the channel to another thread. The Java example puts it on a queue that a starter thread reads.
The server waits for each callback to return before it handles the next connection, so return quickly. If the Java example's queue is full, its callback shuts the channel down rather than wait.
- Rust
- C
- C++
- Java
- C#
async fn setup_channel(
channel: &mut MasterChannel,
source: u16,
) -> Result<AssociationHandle, Box<dyn std::error::Error>> {
let assoc = channel
.add_association(
EndpointAddress::try_new(source)?,
AssociationConfig::new(
EventClasses::all(),
EventClasses::all(),
Classes::all(),
EventClasses::none(),
),
Box::new(NullReadHandler),
Box::new(NullAssociationHandler),
Box::new(NullAssociationInformation),
)
.await?;
channel.enable().await?;
Ok(assoc)
}
// There's no C example yet: call dnp3_master_channel_add_association() and dnp3_master_channel_enable() from another thread
// There's no C++ example yet: call MasterChannel::add_association() and MasterChannel::enable() from another thread
private static void startChannel(StartRequest request, HashMap<UShort, MasterChannel> channels) {
System.out.println("Accept with source == " + request.source + " destination == " + request.destination);
try {
request.channel.addAssociation(
request.source,
new AssociationConfig(
EventClasses.all(),
EventClasses.all(),
Classes.all(),
EventClasses.none()
),
new NullReadHandler(),
new NullAssocHandler(),
new NullAssocInfo()
);
request.channel.enable();
} catch (ParamException ex) {
// for example, the source isn't a valid outstation address
System.out.println("channel setup failed: " + ex.getMessage());
request.channel.shutdown();
return;
}
final MasterChannel previous = channels.put(request.source, request.channel);
if(previous != null) {
previous.shutdown();
}
}
// There's no C# example yet: call MasterChannel.AddAssociation() and MasterChannel.Enable() from another thread
Keep the channel for as long as you want to talk to the outstation. In Rust, dropping the MasterChannel and its
AssociationHandles ends the session; in the bindings, shut the channel down when you're done with it.
The server doesn't track the channels it has created. When an outstation reconnects, it gets a new channel, so shut down the old one. Both examples keep their channels in a map keyed by the outstation's address and replace the old entry.
Shutting down
In Rust, dropping the ServerHandle stops the server. In the bindings, shut down the MasterServer: close() in Java,
Dispose() in C#, its destructor in C++, or dnp3_master_server_destroy() in C.
The server then stops accepting connections. Channels it has already created keep running until you shut them down.