sequenceDiagram
participant C as Client agent
participant M as MAC agent
C->>M: ReservationReq(to, duration)
M-->>C: ReservationRsp (AGREE)
M-->>C: ReservationStatusNtf (START)
note over C: client transmits<br/>for the reservation duration
M-->>C: ReservationStatusNtf (END)
33 Developing agents
By now you are very familiar with the idea of agents — you have interacted with them through commands and messages throughout this handbook. In this chapter we cross over from using agents to writing them. While the prospect of writing your own agent might sound daunting, you’ll soon see that it is actually quite easy.
33.1 Unet agents
Agents are the basic building blocks of UnetStack. They exchange messages, provide services and implement protocols. Most of the demanding work expected of a well-behaved agent is already taken care of by the UnetAgent base class. All you need to do is extend it and add a little code to teach the agent what you want it to do.
You can write agents in Java or Groovy (or any other JVM language), but we recommend Groovy: Groovy agents need less boilerplate, are more readable, and can be loaded dynamically from source without precompilation. If you are an expert Java programmer and prefer Java, you are welcome to use it.
The skeleton of a Groovy agent looks like this:
import org.arl.fjage.*
import org.arl.unet.*
import groovy.transform.CompileStatic
@CompileStatic
class MyAgent extends UnetAgent {
@Override
void setup() {
// called when the stack is initialized
// register services and capabilities that you provide here
}
@Override
void startup() {
// called just after the stack is running
// look up other agents and services here, as needed
// subscribe to topics of interest to get notifications
}
@Override
Message processRequest(Message msg) {
// process requests supported by the agent, and return responses
// if request is not processed, return null
return null
}
@Override
void processMessage(Message msg) {
// process other messages, such as notifications, here
// messages that aren't interesting can simply be ignored
}
@Override
void shutdown() {
// called when the agent is stopped or the stack is shut down
// release any resources (sockets, files, devices) acquired here
}
}The @CompileStatic and @Override annotations are not strictly necessary, but are good practice: they let the compiler catch type errors and typos in method names. You only need to define the methods you actually use — the base class provides sensible defaults for the rest. The lifecycle hooks are called in order: setup() once, then startup() once the stack is running, then processRequest()/processMessage() repeatedly as messages arrive, and finally shutdown() when the agent is killed or the stack stops.
UnetStack is built on the fjåge agent framework, and the UnetAgent hooks map onto the standard fjåge agent lifecycle. A fjåge agent is initialized (init()), runs cooperatively while it has active behaviors, sits idle when it does not, and is cleaned up (shutdown()) before termination. UnetAgent wires the hooks into this lifecycle for you:
setup()is called from the agent’sinit(), before the stack is fully up.startup()is called from aOneShotBehaviorscheduled during initialization, so it runs once the stack is running and all agents have had a chance to register their services.processRequest()/processMessage()are called from aMessageBehavioradded during initialization.shutdown()is called from fjåge’sshutdown()when the agent is stopped.
See the fjåge documentation on Agents & Behaviors for the underlying model.
33.2 A first agent: the echo daemon
The best way to learn is by example. Let’s develop an echo daemon that responds to each incoming echo request datagram with an echo response datagram carrying the same data.
We need a way to tell an echo request apart from any other datagram, so that we don’t echo datagrams intended for other agents. We define an echo request as any datagram with protocol USER (recall that protocol numbers from USER onwards are available for your own applications). We must not echo the response with the same protocol, or the daemon on the source node would echo it again, ad infinitum — so the response uses protocol DATA, which is intended for generic application data.
Here’s the daemon:
import org.arl.fjage.*
import org.arl.unet.*
import groovy.transform.CompileStatic
@CompileStatic
class EchoDaemon extends UnetAgent {
@Override
void startup() {
// subscribe to the topic where datagrams delivered to this node are published
subscribe topic(Topics.DATAGRAM)
}
@Override
void processMessage(Message msg) {
if (msg instanceof DatagramNtf && msg.protocol == Protocol.USER) {
// respond to protocol USER datagram with protocol DATA datagram
send new DatagramReq(
recipient: msg.sender,
to: msg.from,
protocol: Protocol.DATA,
data: msg.data
)
}
}
}Walking through the code:
- Our agent provides no formal services or capabilities, so we skip
setup()andprocessRequest(). startup()subscribes to theDATAGRAMtopic. Whenever a datagram addressed to this node arrives, the agent that received it (a physical layer, link or router agent providing theDATAGRAMservice, see Chapter 16) publishes aDatagramNtfon this topic, andprocessMessage()is called.- In
processMessage(), we check for datagram notifications with protocolUSER, and respond by asking the sender to send a protocolDATAdatagram back to the originating node, copying the data across.
The @CompileStatic annotation deserves special mention. It is not required — the agent works identically without it — but we recommend it for all production agents. By default, Groovy compiles code with dynamic dispatch, resolving methods and properties at runtime; with @CompileStatic, the class is type-checked and compiled statically, like Java. Typos and type errors are then caught when the agent is compiled, rather than surfacing as runtime failures in the middle of a deployment, and the generated bytecode runs faster and allocates less garbage — a difference that matters on resource-constrained modem hardware. If a method genuinely needs Groovy’s dynamic features, annotate just that method with @CompileDynamic and keep the rest of the class static.
Do not confuse sender/recipient with from/to. The sender and recipient always refer to the agents that generate and consume a message, within a single node. The from and to are node addresses identifying which node transmits a datagram and which is its intended destination.
To test the daemon, create a file called EchoDaemon.groovy in the classes folder and paste in the code above. Start the 2-node network simulation, and on node B load the agent:
> container.add 'echo', new EchoDaemon();Our daemon is up and running! Now test it from node A:
> subscribe phy
> phy << new DatagramReq(to: host('B'), protocol: Protocol.USER, data: [42])
AGREE
phy >> TxFrameNtf:INFORM[type:DATA txTime:2809812247]
phy >> RxFrameStartNtf:INFORM[type:DATA rxTime:2811767943]
phy >> RxFrameNtf:INFORM[type:DATA from:31 to:232 rxTime:2811767943 (1 byte)]
> ntf.data
[42]We subscribed to phy to see the incoming echo response, transmitted an echo request with some data, and saw the same data echoed back. We have written our first agent!
If you prefer Java, you can write the same agent in pure Java, at the cost of some verbosity and an extra compilation step:
import org.arl.fjage.Message;
import org.arl.unet.*;
public class EchoDaemon extends UnetAgent {
@Override
protected void startup() {
// subscribe to the topic where datagrams delivered to this node are published
subscribe(topic(Topics.DATAGRAM));
}
@Override
protected void processMessage(Message msg) {
if (msg instanceof DatagramNtf) {
DatagramNtf ntf = (DatagramNtf)msg;
if (ntf.getProtocol() == Protocol.USER) {
// respond to protocol USER datagram with protocol DATA datagram
DatagramReq req = new DatagramReq(ntf.getSender());
req.setTo(ntf.getFrom());
req.setProtocol(Protocol.DATA);
req.setData(ntf.getData());
send(req);
}
}
}
}The differences are the usual Java idioms: explicit casts on Message, getter/setter accessors (getProtocol(), setTo()) in place of Groovy’s property syntax, and explicit parentheses on method calls. One difference that matters later: the Groovy closures used to construct behaviors (e.g. add new WakerBehavior(1000, { ... })) are Groovy syntactic sugar — in Java, you instead create an anonymous subclass and override the appropriate method (onWake(), onTick(), etc.).
Create EchoDaemon.java, then compile it with the fjåge and unet-framework jars on the classpath:
$ javac -cp "lib/*" EchoDaemon.javaCopy the resulting EchoDaemon.class into the classes folder (delete any EchoDaemon.groovy first, to avoid duplicate classes), and test it just as before.
Unet modems also have a classes folder that accepts Groovy source files or compiled Java/Groovy class files. You can upload files through the modem’s web interface, or create and edit them right there in the web shell’s script editor — a convenient workflow when iterating on an agent that runs on the modem. If your code has many classes, package them into a jar and place it in the jars folder.
33.3 Behaviors
Agents implement most of their functionality through behaviors. UnetStack is built on the fjåge agent framework, which provides a set of standard behaviors for agents to use.
We have already used two behaviors implicitly: the startup() method is run by the base class using a OneShotBehavior, and processMessage() is called from a MessageBehavior. The base class adds these for us, because almost every agent needs them.
Suppose we wanted our echo daemon to respond after a delay of 7 seconds. We could naïvely add a delay(7000) in processMessage(), but that would be a bad idea — the agent would sleep for 7 seconds and be unable to process requests from any other node in the meantime. Instead, we want a behavior that fires 7 seconds later without blocking — a WakerBehavior:
@Override
void processMessage(Message msg) {
if (msg instanceof DatagramNtf && msg.protocol == Protocol.USER) {
// respond after 7 seconds
add new WakerBehavior(7000, {
send new DatagramReq(
recipient: msg.sender,
to: msg.from,
protocol: Protocol.DATA,
data: msg.data
)
})
}
}The agent remains responsive while waiting, and can process several requests concurrently — send two echo requests in quick succession, and you’ll get two responses 7 seconds later, also in quick succession.
To reload the agent on node B after editing:
> container.kill echo
true
> container.add 'echo', new EchoDaemon();fjåge provides several behaviors commonly used in Unet agents:
- One-shot behavior — runs once at the earliest opportunity.
- Cyclic behavior — runs repeatedly while active; may be blocked and restarted as needed.
- Waker behavior — runs once after a specified delay (ms).
- Ticker behavior — runs repeatedly with a fixed delay between invocations.
- Backoff behavior — like a waker behavior, but the wakeup time can be extended dynamically; useful for backoff/retry timeouts.
- Poisson behavior — like a ticker behavior, but with exponentially distributed intervals, modelling a Poisson arrival process (commonly used for network data sources).
- Finite state machine behavior — implements protocols as a set of states with event-triggered transitions.
See the fjåge documentation on Agents & Behaviors to learn more.
33.4 Parameters
Many agents expose parameters that you can get and set. Let’s make our echo daemon’s delay configurable. By default the daemon has no parameters:
> echo
<<< EchoDaemon >>>Adding a title, description and a delay parameter takes three steps — declare a parameter list, declare the parameter, and advertise the list:
import org.arl.fjage.*
import org.arl.fjage.param.Parameter
import org.arl.unet.*
import groovy.transform.CompileStatic
@CompileStatic
class EchoDaemon extends UnetAgent {
1 enum Params implements Parameter {
delay
}
2 final String title = 'Echo Daemon'
final String description = 'Echoes any USER datagrams back as DATA'
3 int delay = 7000
@Override
void startup() {
subscribe topic(Topics.DATAGRAM)
}
@Override
void processMessage(Message msg) {
if (msg instanceof DatagramNtf && msg.protocol == Protocol.USER) {
4 add new WakerBehavior(delay, {
send new DatagramReq(
recipient: msg.sender,
to: msg.from,
protocol: Protocol.DATA,
data: msg.data
)
})
}
}
@Override
5 List<Parameter> getParameterList() {
allOf(Params)
}
}- 1
- Declare the list of parameters the agent advertises (here, as an inner enum).
- 2
- Provide a descriptive title and description for the agent.
- 3
- Declare the parameter, with its default value.
- 4
- Use the parameter in the behavior.
- 5
- Advertise the parameter list.
Reload the agent and inspect it:
> echo
<<< Echo Daemon >>>
Echoes any USER datagrams back as DATA
[EchoDaemon.Params]
delay = 7000
> echo.delay = 5000
5000Parameters are much more than class attributes — they can be read and set remotely, even from a different JVM, a different computer, or through a UnetSocket gateway. In Java you’d implement getters and setters explicitly; in Groovy these are generated for you. To compute or validate a parameter on demand, implement a getter/setter and it will be called. For a read-only parameter, declare the attribute private and implement only a getter. Descriptions can be dynamic too — replace the description attribute with a getDescription() method to surface live status.
The title and description attributes give a one-line summary. For richer help — usage notes, parameter and command descriptions that show up under the shell help command — define a static __doc__ string. By convention @@ is a placeholder for the agent’s name, and ## Parameters: / ## Commands: sections (with ### entries) document individual parameters and commands:
public final static String __doc__ = '''\
# @@ - echoes USER datagrams back as DATA
EchoDaemon listens for datagrams sent with protocol USER and echoes their
payload back to the originating node as a protocol DATA datagram.
## Parameters:
### @@.delay - delay (ms) before echoing a datagram
'''.stripIndent()Many of the built-in agents documented in this handbook expose their parameters and commands exactly this way.
33.5 Implementing network protocols
Real-world protocols demand more: advertising services, looking up other agents, computing parameters on demand, encoding/decoding PDUs, generating random variates, and describing behavior as a finite state machine (FSM). We illustrate all of these by developing three simple MAC agents (Chapter 19), kept intentionally unoptimized so the mechanics stay clear.
33.5.1 A simple MAC without handshake
Recall the message flow of the MAC service (Chapter 19): a client asks to reserve the channel with a ReservationReq, and the MAC agent answers with a ReservationRsp. The MAC then tells the client when its reservation starts and ends with ReservationStatusNtf messages — the client transmits its data between the START and the END notifications (Figure 33.1).
Our first MAC grants every reservation request immediately. To comply with the MAC service specification, it must also respond to ReservationCancelReq, ReservationAcceptReq and TxAckReq (which we refuse), and expose the MAC service parameters:
import org.arl.fjage.*
import org.arl.fjage.param.Parameter
import org.arl.unet.*
import org.arl.unet.mac.*
import groovy.transform.CompileStatic
@CompileStatic
class MySimplestMac extends UnetAgent {
@Override
void setup() {
register Services.MAC // advertise the MAC service
}
@Override
Message processRequest(Message msg) {
switch (msg) {
case ReservationReq:
ReservationReq req = (ReservationReq)msg
if (req.duration <= 0) return new RefuseRsp(req, 'Bad reservation duration')
ReservationStatusNtf ntf1 = new ReservationStatusNtf(
recipient: req.sender, inReplyTo: req.messageID, to: req.to,
status: ReservationStatus.START)
ReservationStatusNtf ntf2 = new ReservationStatusNtf(
recipient: req.sender, inReplyTo: req.messageID, to: req.to,
status: ReservationStatus.END)
add new OneShotBehavior({ send ntf1 }) // START immediately
add new WakerBehavior(Math.round(1000*req.duration), { // END after duration
send ntf2
})
return new ReservationRsp(req) // defaults to an AGREE performative
case ReservationCancelReq:
case ReservationAcceptReq:
case TxAckReq:
return new RefuseRsp(msg, 'Not supported')
}
return null // NOT_UNDERSTOOD for anything else
}
@Override
List<Parameter> getParameterList() {
return allOf(MacParam) // advertise MAC service parameters
}
final boolean channelBusy = false // 'final' makes parameters read-only
final int reservationPayloadSize = 0
final int ackPayloadSize = 0
final float maxReservationDuration = Float.POSITIVE_INFINITY
final Float recommendedReservationDuration = null
}Note how we prepare the AGREE response and the START/END notifications together. The OneShotBehavior ensures the START notification is sent after the AGREE response, and the WakerBehavior schedules the END notification at the right time. Returning null for an unrecognized request lets the base class respond with NOT_UNDERSTOOD.
Two small idioms are worth noting, both consequences of static compilation. A case ReservationReq: label does not narrow the type of msg the way an instanceof check does, so we introduce a typed local variable (req) at the top of the case — without it, accesses like msg.duration will not compile. And we read the message ID through the public messageID property; the msgID shorthand seen in older (dynamically compiled) examples reaches into a protected field, which the static compiler rightly rejects.
To try it on the 2-node network, kill the default CSMA mac and load ours:
> container.kill mac
true
> container.add 'mac', new MySimplestMac()
mac
> mac << new ReservationReq(to: 31, duration: 3.seconds)
ReservationRsp:AGREE
mac >> ReservationStatusNtf:INFORM[to:31 status:START]
mac >> ReservationStatusNtf:INFORM[to:31 status:END]The START notification arrives right after the AGREE, and the END about 3 seconds later.
Typing the agent’s name shows that our getParameterList() declaration did its job — the standard MacParam service parameters surface in the shell automatically, with read-only markers on the ones we exposed as plain properties:
> mac
« MAC »
[org.arl.unet.mac.MacParam]
channelBusy ⤇ false
maxReservationDuration ⤇ Infinity
recommendedReservationDuration = null
reservationPayloadSize ⤇ 0Every agent has a Java logger (log) writing to logs/log-0.txt, supporting levels severe, warning, info, fine, finer, finest:
log.fine 'Some debugging information'Control the log level per class or package with the logLevel command (logLevel FINE), and view the last few log lines from the shell with tail. Logs are also available in the web interface “Logs” tab.
When an agent will not even load, the same log file is where to look: if a class in the classes folder fails to compile, the container.add reports an error on the shell, and the compiler’s diagnostics land in logs/log-0.txt. One gotcha to recognize: if the class file is malformed in a way that leaves the shell parser wanting more input (for example, an unbalanced brace), the shell may show a - continuation prompt instead of an error — press Ctrl-C (or enter a lone }) to get back to the normal prompt and check the log. When in doubt, compile the class manually with groovyc -cp "lib/*" MyAgent.groovy and read the compiler output directly.
Logs tell you what one agent did; the event tracing framework tells you why — it records each stimulus (a message received) together with the response it provoked (a message sent), threading causally related events together across agents and even across nodes. To make your agent’s events traceable, wrap the messages it generates in trace(stimulus, response) — a UnetAgent convenience method that is a no-op unless tracing is active:
send trace(req, new DatagramDeliveryNtf(req))
request trace(ntf, req2), timeoutAll the built-in agents do this. Tracing is enabled automatically in simulations (producing logs/trace.json) and can be turned on on real nodes with EventTracer.enable(filename); the resulting trace can be rendered as a sequence diagram of your protocol in action — see Section 34.6.1.
33.5.2 A simple MAC with throttling
The simplest MAC performs poorly under heavy load — every node grabs the channel at once and collisions destroy throughput. We can fix this by adding an exponentially distributed random backoff (a Poisson arrival process), chosen to target a normalized load of about 0.5, which maximizes ALOHA throughput. Here is the full agent:
import org.arl.fjage.*
import org.arl.fjage.param.Parameter
import org.arl.unet.*
import org.arl.unet.mac.*
import org.arl.unet.phy.*
import groovy.transform.CompileStatic
@CompileStatic
class MySimpleThrottledMac extends UnetAgent {
private final static double TARGET_LOAD = 0.5
private final static int MAX_QUEUE_LEN = 16
private AgentID phy
boolean busy = false
Long t0 = null
Long t1 = null
int waiting = 0
@Override void setup() { register Services.MAC }
@Override void startup() { phy = agentForService(Services.PHYSICAL) } // lookup after services are advertised
@Override
Message processRequest(Message msg) {
switch (msg) {
case ReservationReq:
ReservationReq req = (ReservationReq)msg
if (req.duration <= 0) return new RefuseRsp(req, 'Bad reservation duration')
if (waiting >= MAX_QUEUE_LEN) return new RefuseRsp(req, 'Queue full')
ReservationStatusNtf ntf1 = new ReservationStatusNtf(
recipient: req.sender, inReplyTo: req.messageID, to: req.to,
status: ReservationStatus.START)
ReservationStatusNtf ntf2 = new ReservationStatusNtf(
recipient: req.sender, inReplyTo: req.messageID, to: req.to,
status: ReservationStatus.END)
AgentLocalRandom rnd = AgentLocalRandom.current() // repeatable in discrete-event simulation
double backoff = rnd.nextExp(TARGET_LOAD/req.duration/nodes) // rate chosen to hit the target load
long t = currentTimeMillis()
if (t0 == null || t0 < t) t0 = t
t0 += Math.round(1000*backoff)
if (t0 < t1) t0 = t1
long duration = Math.round(1000*req.duration)
t1 = t0 + duration
waiting++
add new WakerBehavior(t0-t, { // START after backoff, END after duration
send ntf1
busy = true
waiting--
add new WakerBehavior(duration, { send ntf2; busy = false })
})
return new ReservationRsp(req)
case ReservationCancelReq:
case ReservationAcceptReq:
case TxAckReq:
return new RefuseRsp(msg, 'Not supported')
}
return null
}
@Override
List<Parameter> getParameterList() {
return allOf(MacParam, Param)
}
enum Param implements Parameter { nodes } // one user-configurable parameter
int nodes = 6 // number of nodes, set by the user
final int reservationPayloadSize = 0
final int ackPayloadSize = 0
final float maxReservationDuration = Float.POSITIVE_INFINITY
boolean getChannelBusy() { return busy } // reflects the real reservation state
float getRecommendedReservationDuration() { // assume one frame per reservation
return (float)get(phy, Physical.DATA, PhysicalChannelParam.frameDuration)
}
}A few points to note. Other agents are looked up in startup(), after they’ve had a chance to advertise their services during setup. Random numbers are drawn from AgentLocalRandom, which gives repeatable results during discrete-event simulation — the preferred way to generate random variates in an agent — and nextExp() returns an exponentially distributed backoff, with a rate chosen to hit the target load. The START notification is no longer immediate: it is scheduled after the backoff, and the END after the reservation duration. We advertise one user-configurable parameter, nodes, and channelBusy now reflects the real reservation state. Finally, recommendedReservationDuration is derived from the PHYSICAL service’s frame duration (Chapter 17), assuming most reservations carry one frame — the generic get() helper returns an Object, so under static compilation we cast it to the declared type.
33.5.3 A simple MAC with handshake
Many MAC protocols (MACA, FAMA, …) use an RTS/CTS handshake. The initiator’s MAC sends a short request-to-send (RTS) PDU to the responder, which answers with a clear-to-send (CTS); on receiving the CTS, the initiator tells its client to go ahead with the transmission. Any neighbor that overhears an RTS or CTS knows the channel is about to be busy and backs off silently — this is what protects the exchange from being trampled by nodes that can hear only one of the two parties. Figure 33.2 shows a successful handshake, together with the messages exchanged between the agents that implement it.
sequenceDiagram
participant C as Client agent<br/>(node A)
participant A as MAC<br/>(node A)
participant B as MAC<br/>(node B)
participant N as MAC<br/>(node C, neighbor)
C->>A: ReservationReq(to: B, duration)
A-->>C: ReservationRsp (AGREE)
note over A: IDLE → RTS<br/>(after a random backoff)
A->>B: RTS PDU
note over B: IDLE → RX
note over N: overhears RTS<br/>IDLE → BACKOFF
B->>A: CTS PDU
note over A: RTS → TX
note over N: overhears CTS<br/>backoff extended
A-->>C: ReservationStatusNtf (START)
note over C,B: client transmits data for the reservation duration
A-->>C: ReservationStatusNtf (END)
note over A: TX → IDLE
Such protocols are naturally described as a finite state machine (FSM). When idle, the agent waits; on a ReservationReq it sends an RTS and moves to the RTS state; a returning CTS moves it to TX (where it tells the client to transmit); after the reservation it returns to idle. If no CTS arrives in time, it retries or fails. An incoming RTS moves it to RX, where it replies with a CTS. Overheard (snooped) RTS/CTS PDUs move it to a BACKOFF state to avoid interfering with other nodes’ exchanges.
We develop the agent as class MySimpleHandshakeMac, statically compiled like the others, and walk through it fragment by fragment. We tag our RTS/CTS PDUs with the MAC protocol number, and define some timeouts:
import org.arl.fjage.*
import org.arl.fjage.param.Parameter
import org.arl.unet.*
import org.arl.unet.mac.*
import org.arl.unet.phy.*
import org.arl.unet.nodeinfo.NodeInfoParam
import groovy.transform.*
@CompileStatic
class MySimpleHandshakeMac extends UnetAgent {
int PROTOCOL = Protocol.MAC
float RTS_BACKOFF = 2 // seconds
float CTS_TIMEOUT = 5 // seconds
float BACKOFF_RANDOM = 5 // seconds
float MAX_PROP_DELAY = 2 // secondsNote that we write the timeouts as plain numbers: the 2.seconds-style unit sugar we enjoy in shell and simulation scripts is a runtime Groovy extension, which a statically compiled class cannot use.
UnetStack provides a PDU class to encode and decode protocol data units declaratively. Our PDU has a type field and a duration field:
int RTS_PDU = 0x01
int CTS_PDU = 0x02
PDU pdu = PDU.withFormat {
uint8('type') // RTS_PDU / CTS_PDU
uint16('duration') // ms
}The PDU class describes a format declaratively; encoding and decoding is then just encode() and decode():
> import java.nio.ByteOrder
> pdu = PDU.withFormat {
- length(16) // 16 byte PDU
- order(ByteOrder.BIG_ENDIAN) // big-endian byte ordering
- uint8('type') // 1 byte field 'type'
- uint8(0x01) // literal byte 0x01
- filler(2) // 2 filler bytes
- uint16('data') // 2 byte field 'data'
- padding(0xff) // pad with 0xff to 16 bytes
- };
> bytes = pdu.encode([type: 7, data: 42])
[7, 1, 0, 0, 0, 42, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1]
> pdu.decode(bytes)
[data:42, type:7]Supported fields include uint8, int8, uint16, int16, uint32, int32, int64 and chars (string), with length, order, filler and padding declarations.
The heart of the protocol is the FSM, drawn in Figure 33.3. Transitions labelled with events (CTS received, overheard RTS/CTS, …) are triggered by incoming PDUs; the remaining transitions are driven by timers started when a state is entered. An action after a slash is performed on entering the target state — for instance, request queued / RTS means that when a reservation request is queued, the agent moves to the RTS state and transmits an RTS PDU.
stateDiagram-v2
direction LR
[*] --> IDLE
IDLE --> RTS: request queued / RTS
RTS --> TX: CTS received
RTS --> IDLE: CTS timeout
TX --> IDLE: reservation over
IDLE --> RX: RTS received / CTS
RX --> IDLE: peer's reservation over
IDLE --> BACKOFF: overheard RTS/CTS
BACKOFF --> BACKOFF: overheard RTS/CTS
BACKOFF --> IDLE: backoff expired
In code, we define the states and events as enums, then build an FSMBehavior using the FSMBuilder utility — each state(...) block corresponds to a state in the diagram, onEvent closures to the event-triggered transitions, and after(...) timers to the timeout transitions:
enum State { IDLE, RTS, TX, RX, BACKOFF }
enum Event { RX_RTS, RX_CTS, SNOOP_RTS, SNOOP_CTS }
int MAX_RETRY = 3
int MAX_QUEUE_LEN = 16
Queue<ReservationReq> queue = new ArrayDeque<ReservationReq>(MAX_QUEUE_LEN)
FSMBehavior fsm = buildFSM()
private FSMBehavior buildFSM() {
return FSMBuilder.build {
int retryCount = 0
double backoff = 0
Map rxInfo
def rnd = AgentLocalRandom.current()
state(State.IDLE) {
action {
if (!queue.isEmpty()) {
after(rnd.nextDouble(0, BACKOFF_RANDOM)) { setNextState(State.RTS) }
}
block()
}
onEvent(Event.RX_RTS) { Map info -> rxInfo = info; setNextState(State.RX) }
onEvent(Event.SNOOP_RTS) { backoff = RTS_BACKOFF; setNextState(State.BACKOFF) }
onEvent(Event.SNOOP_CTS) { Map info ->
backoff = (double)info.duration + 2*MAX_PROP_DELAY
setNextState(State.BACKOFF)
}
}
state(State.RTS) {
onEnter {
ReservationReq msg = queue.peek()
def bytes = pdu.encode(type: RTS_PDU, duration: Math.ceil(msg.duration*1000))
phy << new TxFrameReq(to: msg.to, type: Physical.CONTROL, protocol: PROTOCOL, data: bytes)
after(CTS_TIMEOUT) {
if (++retryCount >= MAX_RETRY) {
sendReservationStatusNtf(queue.poll(), ReservationStatus.FAILURE)
retryCount = 0
}
setNextState(State.IDLE)
}
}
onEvent(Event.RX_CTS) { setNextState(State.TX) }
}
state(State.TX) {
onEnter {
ReservationReq msg = queue.poll()
retryCount = 0
sendReservationStatusNtf(msg, ReservationStatus.START)
after(msg.duration) {
sendReservationStatusNtf(msg, ReservationStatus.END)
setNextState(State.IDLE)
}
}
}
state(State.RX) {
onEnter {
def bytes = pdu.encode(type: CTS_PDU, duration: Math.round((double)rxInfo.duration*1000))
phy << new TxFrameReq(to: (int)rxInfo.from, type: Physical.CONTROL, protocol: PROTOCOL, data: bytes)
after((double)rxInfo.duration + 2*MAX_PROP_DELAY) { setNextState(State.IDLE) }
rxInfo = null
}
}
state(State.BACKOFF) {
onEnter { after(backoff) { setNextState(State.IDLE) } }
onEvent(Event.SNOOP_RTS) { backoff = RTS_BACKOFF; reenterState() }
onEvent(Event.SNOOP_CTS) { Map info ->
backoff = (double)info.duration + 2*MAX_PROP_DELAY
reenterState()
}
}
}
}Notice that the whole agent — FSM included — compiles statically. FSMBuilder implements a little domain-specific language, in which calls like state(...), onEnter, onEvent, after and setNextState are not methods of our agent: they are resolved against the builder and FSM objects through closure delegates, which fjåge declares with @DelegatesTo annotations so the static compiler can verify them. The only concession to static typing is at the loosely-typed edge of the FSM: the info delivered with each event is declared as a Map closure parameter, and its entries are cast where they are used.
Three design details are easy to miss in the code, because they don’t appear in the FSM diagram. First, the IDLE state does not fire off an RTS the instant a request is queued — it waits a random delay (rnd.nextDouble(0, BACKOFF_RANDOM)) first, so that on a loaded network two nodes with queued requests don’t keep colliding their RTS PDUs. Second, the retryCount/MAX_RETRY logic in the RTS state gives up on a reservation after a few failed handshake attempts, reporting FAILURE to the requester rather than retrying forever. Third, backoff is a variable, deliberately set before each transition into BACKOFF: an overheard RTS warrants only a short wait (RTS_BACKOFF — the handshake may yet fail), while an overheard CTS means a reservation is definitely starting, so we back off for its whole duration plus propagation allowance.
The FSMBuilder makes setting up an FSM behavior easy:
- Define states and events as
enumdeclarations. - Build the
FSMBehaviorwithFSMBuilder.build, with astate(...)for each state. - In each state, use
action(run continuously, like a cyclic behavior — callblock()/restart()to avoid busy loops),onEnter/onExit(run on entry/exit),onEvent(triggered viatrigger()), andafter(timers fired a set time after entry). - Use
setNextState()andreenterState()to effect transitions. - For short-lived FSMs, call
terminate()when done.
A small helper sends the reservation status notifications:
void sendReservationStatusNtf(ReservationReq msg, ReservationStatus status) {
send new ReservationStatusNtf(
recipient: msg.sender, inReplyTo: msg.messageID,
to: msg.to, from: addr, status: status)
}We wire everything together in setup() and startup() — registering the MAC service, looking up the PHYSICAL service, subscribing to its topic (to overhear other nodes’ PDUs) and to the DATAGRAM topic (for PDUs addressed to us), looking up our own address, and starting the FSM:
AgentID phy
int addr
void setup() {
register Services.MAC
}
void startup() {
phy = agentForService(Services.PHYSICAL)
subscribe topic(phy) // overheard (snooped) frames
subscribe topic(Topics.DATAGRAM) // frames addressed to this node
add new OneShotBehavior({
def nodeInfo = agentForService(Services.NODE_INFO)
addr = (int)get(nodeInfo, NodeInfoParam.address)
})
add(fsm)
}Why does the address lookup sit inside a OneShotBehavior rather than running directly in startup()? The get() call blocks while it queries the node-info agent, which may itself still be starting up. Wrapping it in a behavior lets startup() return immediately — the lookup then runs once the agent (and the rest of the stack) is up, without holding anything else back.
Here we looked up one PHYSICAL provider and subscribed to its topic. When an agent instead wants notifications from every provider of a service — including providers that are loaded later — it can call subscribeForService(service), which tracks the providers of the service and keeps the subscriptions up to date.
Incoming PDUs arrive as RxFrameNtf messages; we decode them and trigger the matching FSM events — RX_* for PDUs destined to us, SNOOP_* for those we overhear. PDU.decode() returns a map of field values, so we cast the duration to a number before converting it to seconds:
void processMessage(Message msg) {
if (msg instanceof RxFrameNtf && msg.protocol == PROTOCOL) {
def rx = pdu.decode(msg.data)
def info = [from: msg.from, to: msg.to, duration: ((int)rx.duration)/1000.0]
if (rx.type == RTS_PDU)
fsm.trigger(info.to == addr ? Event.RX_RTS : Event.SNOOP_RTS, info)
else if (rx.type == CTS_PDU)
fsm.trigger(info.to == addr ? Event.RX_CTS : Event.SNOOP_CTS, info)
}
}As before, we expose the MAC service parameters, with channelBusy now reflecting whether the FSM is in a non-idle state. Unlike the earlier MACs, maxReservationDuration is now finite — our PDU carries the reservation duration in a uint16 millisecond field, which caps out at 65535 ms, so the parameter advertises exactly that limit (and the ReservationReq validation below enforces it):
@Override
List<Parameter> getParameterList() {
return allOf(MacParam)
}
final int reservationPayloadSize = 0
final int ackPayloadSize = 0
final float maxReservationDuration = 65.535 // uint16 ms field in the PDU
final Float recommendedReservationDuration = null
boolean getChannelBusy() {
return fsm.currentState != State.IDLE
}Finally, a ReservationReq is validated, queued, and the FSM restarted:
@Override
Message processRequest(Message msg) {
switch (msg) {
case ReservationReq:
ReservationReq req = (ReservationReq)msg
if (req.to == Address.BROADCAST || req.to == addr)
return new RefuseRsp(req, 'Reservation must have a destination node')
if (req.duration <= 0 || req.duration > maxReservationDuration)
return new RefuseRsp(req, 'Bad reservation duration')
if (queue.size() >= MAX_QUEUE_LEN)
return new RefuseRsp(req, 'Queue full')
queue.add(req)
fsm.restart() // wake the fsm, which blocks when the queue is empty
return new ReservationRsp(req)
case ReservationCancelReq:
case ReservationAcceptReq:
case TxAckReq:
return new RefuseRsp(msg, 'Not supported')
}
return null
}To test the handshake MAC, the protocol must run on all nodes, so load MySimpleHandshakeMac on both node A and node B (after killing the default mac). Subscribe to phy on node A and make a reservation, and you’ll see the RTS/CTS PDUs exchanged before the reservation starts:
> subscribe phy
> mac << new ReservationReq(to: 31, duration: 3.seconds)
ReservationRsp:AGREE
phy >> TxFrameStartNtf:INFORM[type:CONTROL txTime:3631928985 txDuration:950]
phy >> RxFrameStartNtf:INFORM[type:CONTROL rxTime:3634151681]
phy >> RxFrameNtf:INFORM[type:CONTROL from:31 to:232 protocol:4 rxTime:3634151681 (3 bytes)]
mac >> ReservationStatusNtf:INFORM[to:31 from:232 status:START]
mac >> ReservationStatusNtf:INFORM[to:31 from:232 status:END]The reservation begins once the handshake completes, and ends 3 seconds later — exactly as designed.
The walkthrough above showed the agent in fragments; the complete, tested source of MySimpleHandshakeMac is listed in Chapter 37. All three MAC agents from this chapter (MySimplestMac, MySimpleThrottledMac and MySimpleHandshakeMac) also ship in the samples folder of the Unet distribution, ready to load and experiment with.
Advertising services and optional capabilities, honouring requests, and publishing notifications are all straightforward to implement. For a worked example of a PHYSICAL service agent (a modem driver), see this blog article.
33.6 Fragmentation and reassembly
An agent that delivers datagrams larger than the frames of the layer below — a link agent carrying multi-frame datagrams over the physical layer, or a transport agent carrying large transfers over a link — has to split the data into fragments on transmission and stitch them back together on reception. Rather than have every such agent reinvent this, UnetStack provides a fragmentation framework in org.arl.unet.utils: a Fragmenter interface that turns a byte array into fragments, and a matching Reassembler that reconstructs the original data from them.
The contract is deliberately simple. A fragmenter is set up with the data and a fragment size, and then iterated:
import org.arl.unet.utils.*
Fragmenter frag = new SimpleFragmenter().setup(data, fragLen)
while (frag.hasMoreFragments()) {
byte[] fragment = frag.nextFragment()
// transmit the fragment, e.g. as one frame
}A reassembler is set up with the expected data length and the same fragment size, and fed fragments as they arrive (in any order) until it reports completion:
Reassembler reasm = new SimpleReassembler().setup(dataLen, fragLen)
// as each fragment arrives:
reasm.addFragment(fragment)
if (reasm.hasFinishedReassembly()) {
byte[] data = reasm.getData()
// deliver the reassembled datagram
}Both sides also expose introspection methods (getMinFragmentCount(), getMaxFragmentCount(), getFragmentCount(), etc.) for sizing headers and tracking progress. The framework does not dictate how fragments travel — your protocol still decides what per-fragment header (source, sequence number, data length, …) it needs so that the receiver can direct each fragment to the right reassembler.
Two implementations ship with UnetStack:
SimpleFragmenter/SimpleReassembler— each fragment carries a small offset header (2 bytes, or 4 bytes for data of 64 kB or more) within the fragment size you specify. Every fragment is required for reassembly, so pair it with a reliable delivery mechanism (or accept that a lost fragment costs the whole datagram). This is whatUdpLinkuses over IP networks.ECFragmenter/ECReassembler— an erasure-coded engine: the fragmenter can generate more fragments than the minimum needed, and the reassembler reconstructs the data from any sufficiently large subset, no matter which fragments were lost. This suits half-duplex, high-latency channels where per-fragment acknowledgements are expensive — it is the engine inside the default underwater link,ECLink(Section 18.6.1).
33.7 Writing shell extensions
All the service commands used throughout this handbook — routes, addroute, bbrec, pclr, and the rest — are not built into the shell. Each is contributed by a shell extension: a class that the shell loads to gain a set of commands, variables and help topics (Chapter 30). When your agent would benefit from convenient shell commands — and any agent an operator interacts with does — you write one the same way.
A shell extension is a class implementing the (empty) marker interface org.arl.fjage.shell.ShellExtension, on which the shell recognizes a few conventions:
- A
static void __init__(ScriptEngine engine)method, if present, is called when the extension is loaded into a shell. Use it to import classes into the shell’s namespace and to keep the engine for later use — through it, your commands can read shell variables, most usefully__agent__, the shell agent itself, which lets a command send and receive messages on the user’s behalf. - Public static methods become shell commands (
routes 2callsroutes(2)), and static getters become shell variables (getRoutes()makes the bare commandrouteswork). - A
public final static String __doc__markdown string provides thehelpentries, in the same format used to document agents (Chapter 31). - Errors are reported by throwing
ShellCommandFailedwith a user-facing message.
The router’s own extension shows all of these conventions in a few lines (abridged from org.arl.unet.net.RouterShellExt):
import org.arl.fjage.AgentID
import org.arl.fjage.Performative
import org.arl.fjage.shell.*
import org.arl.unet.*
1class RouterShellExt implements ShellExtension {
public final static String __doc__ = '''\
# router - routing service
## Commands:
### routes - print routing table
Examples:
routes // display routing table
routes 2 // display routes to node 2
2'''.stripIndent()
static private final ThreadLocal<ScriptEngine> engine = new ThreadLocal<ScriptEngine>()
3 static void __init__(ScriptEngine engine) {
this.engine.set(engine)
engine.importClasses('org.arl.unet.net.*')
}
4 static AgentID getRouter() {
return ShellExtUtils.selectAgentID(engine.get(), Services.ROUTING, 'router')
}
5 static String routes(int to = -1) {
def rr = router
6 if (rr == null) throw new ShellCommandFailed('Router not found')
def req = new GetRouteReq(to: to, all: true)
rr.send(req)
7 def agent = engine.get().getVariable('__agent__')
def s = ''
def rsp = agent.receive(req, 1000)
while (rsp) {
if (rsp instanceof RouteInfo) s += "$rsp\n" // format table row
if (rsp.performative == Performative.AGREE) break
rsp = agent.receive(req, 1000)
}
return s ?: 'No routes'
}
}- 1
- The marker interface that tells the shell this class contributes commands.
- 2
-
The
__doc__string feedshelp routerandhelp routes— and, via theDOCUMENTATIONservice, this handbook’s own command tables. - 3
-
Called on load; the engine is kept in a
ThreadLocal(one per shell), and useful classes are imported so users can typeGetRouteRequnqualified. - 4
-
A static getter — in the shell, typing
routernow resolves to the routing agent’sAgentID. TheShellExtUtils.selectAgentIDhelper finds the agent providing a service, preferring one whose name matches (and letting the user disambiguate by setting a shell variable when several match). - 5
-
A static method — in the shell,
routesorroutes 2invokes it, and its return value is displayed. - 6
-
Failures are signalled with
ShellCommandFailed, which the shell reports cleanly. - 7
-
Commands run in the shell agent’s context:
__agent__lets the command use the shell agent to send requests and collect responses.
Loading an extension:
Put your extension class in the classes folder (or a jar in jars), like any custom agent. Extensions are then loaded into a shell with the shell’s run command using a cls:// URL — UnetStack’s own extensions are loaded exactly this way in etc/fshrc.groovy. To load your own automatically in every Groovy shell, add a line to a fshrc.groovy script in the scripts folder (the same startup script that holds convenience closures, Chapter 3):
run 'cls://my.pkg.MyShellExt'On AT shells (Chapter 12), extensions are loaded at runtime with AT~EXT=my.pkg.MyShellExt instead.
33.8 Do’s and don’ts
A few practices keep agents fast, robust and consistent with the rest of the stack.
Do
- Compile Groovy agents statically. Annotate your agent class with
@CompileStatic(fromgroovy.transform). Static compilation catches type errors at build time and runs much faster than dynamic dispatch — important on resource-constrained modems. Where you genuinely need dynamic features in a method, exclude just that method with@CompileDynamic. One caveat: do not build an FSM inside a non-static inner class — the Groovy compiler mis-resolves the DSL closures there (the code compiles, then fails at runtime). Make the state-holder astaticnested class carrying an explicit reference to its agent instead; any agent member you forget to qualify through that reference then fails at compile time rather than at sea. - Define parameters with an inner
enumimplementingParameter, and provide a__doc__string (as shown in Chapter 33 above). This is what makes your parameters and commands discoverable from the shell and in this handbook. - Ship a shell extension for operator-facing agents (Section 33.7) — commands documented under
helpand loadable into any shell beat ad-hoc convenience closures infshrc.groovy. - Register services and capabilities in
setup(), but defer looking up other agents’ services tostartup()— atsetup()time other agents may not have registered yet. - Do all timing and waiting with behaviors —
WakerBehavior,TickerBehavior,BackoffBehavior, anFSMBehavior, etc. — so the agent stays responsive to other messages. - Reply to requests by returning a response from
processRequest()(anAGREE/REFUSE/FAILUREor a typed response), and report asynchronous outcomes by publishing notifications on your topic. - Use protocol numbers
Protocol.USERand above for your own application protocols; the lower numbers are reserved (see Chapter 16).
Don’t
- Don’t block the agent’s thread. Never call
delay()/Thread.sleep()or do blocking I/O insideprocessMessage()/processRequest()— the agent cannot handle anything else while blocked. Schedule the work in a behavior instead. - Don’t do heavy work — or service lookups — in the constructor. The container and other agents may not be ready. Use
setup()/startup(). - Don’t bypass the message API. Interact with other agents by sending messages (request/response, notifications), not by calling their methods directly; this keeps agents decoupled and lets them live on different nodes (see Chapter 11).
- Don’t create feedback loops. When echoing or forwarding datagrams, change the protocol number (or otherwise mark them) so a response is not itself re-processed and forwarded forever.