API
Every call on the one-object API, grouped by what you are trying to do.
If you have not used the library before, read
Getting started first — it explains the ideas these calls
rest on. This page assumes them and is here to be searched.
Best practices covers how to use them well, and
Recipes has worked examples.
- Opening
- Being a device
- Being a client
- Values bigger than a register
- Watching
- Being told things
- Safety, identity and access
- Diagnostics and tuning
- The bridge
- Reaching past the facade
- Types and enumerations
- Build-time settings
Throughout: unit is the device's Modbus address (1–247), address is a
wire address counted from 0, and done is an optional completion handler
that may be left off entirely.
One thread per node. A node does no locking of its own, so call all of its
methods, poll() included, from the same thread or task. Separate nodes are
independent of each other.
Opening
#include <ModBeeModbus.h>
ModbusRTU · ModbusASCII
bool begin(Stream* serial, uint32_t baud); // Arduino
bool begin(const char* device, uint32_t baud,
ModbusParity parity = ModbusParity::Even); // host
bool begin(ModbusTransport& transport, uint32_t baudForTiming = 0); // ModbusRTU: any transport
bool begin(ModbusTransport& transport); // ModbusASCII: any transport
void directionPin(int pin, bool activeHigh = true, uint32_t holdUs = 2000); // Arduino
void slave(uint8_t unitId);
begin() opens the link as a client. Add slave(unitId) to answer as a server
as well; a node may be both at once on one segment.
On Arduino, open the Stream yourself first (Serial1.begin(19200, SERIAL_8E1))
and pass the same baud rate to begin(): RTU framing is defined in character
times. On a host, device is "/dev/ttyUSB0", a pseudo-terminal, or "COM3"
on Windows; see ModbusParity for the serial formats.
directionPin() drives an RS-485 transceiver's DE/RE line; call it before
begin(). holdUs is how long the driver stays enabled after the last byte —
too short and the last character is truncated on the wire.
baudForTiming on the RTU transport form only affects RTU character timing;
pass 0 to take it from the transport.
RTU only:
void forceFraming(ModbusFraming framing); // Gap or Length, if you must
ModbusRtuCodec& codec(); // the framer itself
framingReason(), on every node, says which framing is in use and why, in a
sentence; see Diagnostics and tuning.
ModbusTCP
bool connect(const char* host, uint16_t port = MODBUS_TCP_PORT); // as a client
bool connect(ModbusTransport& transport);
bool listen(uint16_t port = MODBUS_TCP_PORT); // as a server
bool listen(ModbusListener& listener);
void maxClients(size_t limit); // how many at once (8)
void idleTimeout(mb_time_t ms); // close one that has gone silent (60 s; 0 disables)
void displaceUnusedConnections(bool enabled); // (on)
void reconnectAfter(mb_time_t ms); // how soon to re-open a dropped link (2 s; 0 disables)
uint32_t reconnects() const; // times the link has been re-opened
size_t clients() const;
uint32_t refused() const; // turned away: every slot held by a client
uint32_t displaced() const; // sockets evicted that never asked anything
void rtuOverTcp(bool enabled); // an RTU frame inside a TCP stream
On a host, connect() and listen() take a host name or address and a port;
listen() binds to every interface. A TCP server answers every unit id,
because a client reaching it over TCP has already addressed it by IP, so there
is no slave() call. reconnectAfter() and reconnects() belong to every node,
not only TCP: a serial port that disappears and comes back is re-opened the same
way.
rtuOverTcp() is what most terminal servers produce, sharing port 502 with the
real thing. Call it before listen() or connect(). It also selects length
framing, which is the only honest choice inside a stream that has no silence in
it — see Recipes.
When every slot is taken, a newcomer may displace a socket that has never
asked for anything and has sat there for several seconds — a scan, a half-open
connection, a client that died before its first request. A connection that has
completed even one request is never displaced, however long it then goes quiet,
so a client polling once a minute keeps its slot. When every slot is held by
such a client the newcomer is refused, promptly, and refused() counts it.
On Arduino, pass an adapter around the platform's own socket classes: any
core's Server and Client pair, WiFiServer and WiFiClient for example.
The server adapter's second argument is how many connections it holds (2 if
left out, no upper limit of its own): a slot for each is taken when it is made,
so a connection costs nothing later, and ready() is false when the board could
not find the memory — check it, because listen() then fails rather than
holding fewer. The node holds at most the smaller of this and maxClients().
WiFiServer socketServer(MODBUS_TCP_PORT);
ModbusArduinoServer<WiFiServer, WiFiClient> listener(socketServer, 3);
if (!listener.ready()) { /* not enough memory for three */ }
mb.listen(listener);
WiFiClient socket;
ModbusArduinoClientTransport<WiFiClient> transport(socket, "192.168.1.50", 502);
mb.connect(transport);
Common to all
void poll(); // call as often as you can; this is the whole contract
void end();
bool connected() const;
Being a device
Binding variables
bool addCoil(uint16_t address, bool* variable); // a bit anyone may write
bool addIsts(uint16_t address, bool* variable); // a bit only you may write
bool addHreg(uint16_t address, uint16_t* variable); // a register anyone may write
bool addIreg(uint16_t address, uint16_t* variable); // a register only you may write
bool addCoils(uint16_t address, bool* array, uint16_t count);
bool addIsts (uint16_t address, bool* array, uint16_t count);
bool addHregs(uint16_t address, uint16_t* array, uint16_t count);
bool addIregs(uint16_t address, uint16_t* array, uint16_t count);
bool addString(uint16_t address, char* buffer, uint16_t capacity);
bool remove(ModbusTable table, uint16_t address);
A bound variable is the register. Reading and writing it in your own code is
all there is to it — there is no publish step.
The array forms bind a contiguous block: one entry and one lookup instead of
count of them. The variable must outlive the binding, so a local is a bug the
compiler will not catch.
addString() maps text across registers, two characters each, the first in the
high byte — the convention almost everything uses.
Registers with no variable behind them
bool addHregHandler(uint16_t address, uint16_t count,
ModbusRegisterReader reader, ModbusRegisterWriter writer = nullptr);
bool addIregHandler(uint16_t address, uint16_t count, ModbusRegisterReader reader);
bool addCoilHandler(uint16_t address, uint16_t count,
ModbusBitReader reader, ModbusBitWriter writer = nullptr);
typedef ModbusCallback<uint16_t(uint16_t offset)> ModbusRegisterReader;
typedef ModbusCallback<bool(uint16_t offset, uint16_t value)> ModbusRegisterWriter;
typedef ModbusCallback<bool(uint16_t offset)> ModbusBitReader;
typedef ModbusCallback<bool(uint16_t offset, bool value)> ModbusBitWriter;
offset counts from the binding's own start address, not from zero. A writer
returning false refuses the write, which the client sees as an Illegal Data
Value exception.
mb.addHregHandler(100, 4,
[](uint16_t offset) -> uint16_t { return readChannel(offset); },
[](uint16_t offset, uint16_t value) { return setChannel(offset, value); });
Handlers are called while the request is being served, so keep them short —
no blocking, no delays.
What a handler can be
Every handler the library stores (ModbusDone, the register handlers above, the
change, failsafe and request handlers below) is a ModbusCallback. It accepts
any of:
- a plain function, or a lambda with no captures — stored as a pointer, no heap;
- a function taking a
void*context first, with the context beside it —
static void onDone(void* context, ModbusResult result)passed as
ModbusDone(onDone, &state)— also no heap; - a lambda with captures, or a
std::function— copied to the heap, as a
std::functionwould be.
On a small board, prefer the first two. ModbusEventHandler and
ModbusLink::TraceHandler are plain function pointers with a context argument,
so a lambda passed to onEvent() or onTrace() must not capture.
Reading and writing your own registers
bool Coil(uint16_t address) const; void Coil(uint16_t address, bool value);
bool Ists(uint16_t address) const; void Ists(uint16_t address, bool value);
uint16_t Hreg(uint16_t address) const; void Hreg(uint16_t address, uint16_t value);
uint16_t Ireg(uint16_t address) const; void Ireg(uint16_t address, uint16_t value);
bool setString(uint16_t address, const char* text);
bool getString(uint16_t address, char* out, uint16_t size) const;
Usually unnecessary — you bound your variable, so use it. These are for a
register served by a handler, or one whose binding is out of scope.
Setting a value here raises no change event: a change event reports somebody
else acting on you.
Knowing that a client wrote
uint16_t map().writeCount(ModbusTable, uint16_t address) const; // one address
uint32_t map().bindingWrites(ModbusTable, uint16_t address) const; // the whole binding
uint32_t map().totalWrites() const; // everything
uint16_t map().failsafeCount(ModbusTable, uint16_t address) const;
Three scopes, because they answer three different questions and each of the
others gets one of them wrong:
writeCount()is per address, and held for the 64 most recently written.
Right for watching one command register. A client sweeping a large block ages
it out, which is exactly when a range is interesting.bindingWrites()is per binding — has anything in MY range been
written — and cannot age out. Right for a block that publishes an array and
wants an edge when a client touches any of it. Two bindings never see each
other's traffic.totalWrites()is the whole map, so it is the honest answer to is
anything writing to me at all and nothing narrower.
Being a client
Nothing blocks. Every call queues and returns; the answer lands in the variable
you pointed at, on a later poll(). dest must stay valid until then.
The value returned says the request was accepted, not that it succeeded.
ModbusResult readCoil (uint8_t unit, uint16_t address, bool& dest, ModbusDone done = nullptr);
ModbusResult readCoils(uint8_t unit, uint16_t address, bool* dest, uint16_t count, ModbusDone done = nullptr);
ModbusResult readIsts (uint8_t unit, uint16_t address, bool& dest, ModbusDone done = nullptr);
ModbusResult readIsts (uint8_t unit, uint16_t address, bool* dest, uint16_t count, ModbusDone done = nullptr);
ModbusResult readHreg (uint8_t unit, uint16_t address, uint16_t& dest, ModbusDone done = nullptr);
ModbusResult readHregs(uint8_t unit, uint16_t address, uint16_t* dest, uint16_t count, ModbusDone done = nullptr);
ModbusResult readIreg (uint8_t unit, uint16_t address, uint16_t& dest, ModbusDone done = nullptr);
ModbusResult readIregs(uint8_t unit, uint16_t address, uint16_t* dest, uint16_t count, ModbusDone done = nullptr);
ModbusResult writeCoil (uint8_t unit, uint16_t address, bool value, ModbusDone done = nullptr);
ModbusResult writeCoils(uint8_t unit, uint16_t address, const bool* values, uint16_t count, ModbusDone done = nullptr);
ModbusResult writeHreg (uint8_t unit, uint16_t address, uint16_t value, ModbusDone done = nullptr);
ModbusResult writeHregs(uint8_t unit, uint16_t address, const uint16_t* values, uint16_t count, ModbusDone done = nullptr);
Writes copy their values immediately, so the array you pass may go out of
scope the moment the call returns. Reads do not: their destination is filled in
later.
Quantity limits are the protocol's, and a request past them is refused as
InvalidArgument rather than truncated: 2000 bits or 125 registers per
read, 1968 bits or 123 registers per write.
The two special ones
ModbusResult maskHreg(uint8_t unit, uint16_t address, uint16_t andMask, uint16_t orMask,
ModbusDone done = nullptr);
Sets and clears individual bits of one register atomically — the only
read-modify-write Modbus has. Without it, two clients setting different bits of
one register erase each other's.
result = (current AND andMask) OR (orMask AND NOT andMask)
ModbusResult readWriteHregs(uint8_t unit,
uint16_t readAddress, uint16_t* dest, uint16_t readCount,
uint16_t writeAddress, const uint16_t* values, uint16_t writeCount,
ModbusDone done = nullptr);
Writes and reads in one transaction, write first — for setting a command and
reading back its result without another device getting a turn in between. The
write half is limited to 121 registers, not 123.
Asking a device about itself
ModbusResult readDeviceId(uint8_t unit, uint8_t* dest, uint16_t capacity,
ModbusDone done = nullptr);
ModbusResult ping(uint8_t unit, ModbusDone done = nullptr);
ping() sends data and expects the same back — the standard link test. It proves
the path end to end without touching a single register, which makes it the right
first question when a device will not answer.
What the queue does for you
| You do this | The library does this |
|---|---|
| The same read twice, before the first goes out | returns Duplicate. One request on the wire. |
| The same write twice | returns Duplicate. |
| Two different values to one address, before transmission | the queued value is replaced; the first caller is told Superseded |
| Queue a write identical to one already on the wire | queues behind it — not merged, because an echo confirming a value that was never sent would be a lie |
| Queue when the queue is full | returns QueueFull. Never a silent drop. |
| Nothing comes back | retries with backoff and jitter, then Timeout |
| Queue more work inside a completion handler | safe: the operation is removed from the queue before its handler runs |
Every accepted operation reaches exactly one conclusion.
Values bigger than a register
Nothing on the wire says which way round a 32-bit value is laid out across two
registers, so the ordering is an argument, named the way device manuals name it.
ModbusResult readFloat (uint8_t unit, uint16_t address, float& dest, ModbusOrder order = ModbusOrder::ABCD, ModbusDone done = nullptr);
ModbusResult readInt32 (uint8_t unit, uint16_t address, int32_t& dest, ModbusOrder order = ModbusOrder::ABCD, ModbusDone done = nullptr);
ModbusResult readUInt32(uint8_t unit, uint16_t address, uint32_t& dest, ModbusOrder order = ModbusOrder::ABCD, ModbusDone done = nullptr);
ModbusResult readDouble(uint8_t unit, uint16_t address, double& dest, ModbusOrder order = ModbusOrder::ABCD, ModbusDone done = nullptr);
ModbusResult readInt64 (uint8_t unit, uint16_t address, int64_t& dest, ModbusOrder order = ModbusOrder::ABCD, ModbusDone done = nullptr);
ModbusResult writeFloat (uint8_t unit, uint16_t address, float value, ModbusOrder order = ModbusOrder::ABCD, ModbusDone done = nullptr);
ModbusResult writeInt32 (uint8_t unit, uint16_t address, int32_t value, ModbusOrder order = ModbusOrder::ABCD, ModbusDone done = nullptr);
ModbusResult writeUInt32(uint8_t unit, uint16_t address, uint32_t value, ModbusOrder order = ModbusOrder::ABCD, ModbusDone done = nullptr);
ModbusResult writeDouble(uint8_t unit, uint16_t address, double value, ModbusOrder order = ModbusOrder::ABCD, ModbusDone done = nullptr);
ModbusResult writeInt64 (uint8_t unit, uint16_t address, int64_t value, ModbusOrder order = ModbusOrder::ABCD, ModbusDone done = nullptr);
ModbusResult probeOrder(uint8_t unit, uint16_t address, ModbusOrderProbe& dest,
ModbusDone done = nullptr);
ModbusResult readText (uint8_t unit, uint16_t address, char* dest, uint16_t capacity,
uint16_t registers, ModbusDone done = nullptr);
ModbusResult writeText(uint8_t unit, uint16_t address, const char* text,
uint16_t registers, ModbusDone done = nullptr);
Banks of them
A meter publishing eight float32 totals is one request, not eight. These live on
client() rather than on the facade, because the facade keeps the short form
short:
mb.client().readFloat32Array (unit, address, float* dest, count, order, done, stride, table);
mb.client().readInt32Array (unit, address, int32_t* dest, count, order, done, stride, table);
mb.client().readUInt32Array (unit, address, uint32_t* dest, count, order, done, stride, table);
mb.client().readFloat64Array (unit, address, double* dest, count, order, done, stride, table);
mb.client().readInt64Array (unit, address, int64_t* dest, count, order, done, stride, table);
mb.client().writeFloat32Array (unit, address, const float* values, count, order, done, stride);
mb.client().writeInt32Array (unit, address, const int32_t* values, count, order, done, stride);
mb.client().writeUInt32Array (unit, address, const uint32_t* values, count, order, done, stride);
mb.client().writeFloat64Array (unit, address, const double* values, count, order, done, stride);
mb.client().writeInt64Array (unit, address, const int64_t* values, count, order, done, stride);
// For a caller that works its element type out at run time rather than naming
// it - a binding behind a generic pin does exactly that.
mb.client().readWideArray (unit, address, void* dest, ModbusWideType, order, count, stride, done, table);
mb.client().writeWideArray(unit, address, const void* values, ModbusWideType, order, count, stride, done);
One request rather than one per value is the whole point: eight setpoints
are eight round trips or one, and on a serial segment that is the difference
between a scan and a second. It also means a set of values that only mean
anything together cannot half-arrive.
stride is the gap in the caller's layout, in bytes; 0 means the values are
packed. It exists because an array element can carry more than its value — a
PLC's do — and without it a bank lands in the wrong slots and reads as plausible
nonsense rather than as an error.
table is HoldingRegister by default and InputRegister where the device
publishes there, which is at least as common. Every scalar read above takes it
too, after done. A bit list is refused: it cannot carry a 32-bit value.
A write past what one request may carry — 123 registers — is refused before
anything reaches the wire, rather than found out from a device that may not
bother to raise an exception.
A wrong word order is not an error. It succeeds and gives a different number,
which is why probeOrder() exists: it reads two registers and hands back every
interpretation of them at once.
struct ModbusOrderProbe {
float asFloat32[4]; // indexed by ModbusOrder
int32_t asInt32[4];
uint32_t asUInt32[4];
};
Watching
size_t watchHreg (uint8_t unit, uint16_t address, uint16_t* dest, uint16_t count, mb_time_t everyMs);
size_t watchIreg (uint8_t unit, uint16_t address, uint16_t* dest, uint16_t count, mb_time_t everyMs);
size_t watchCoil (uint8_t unit, uint16_t address, bool* dest, uint16_t count, mb_time_t everyMs);
size_t watchIsts (uint8_t unit, uint16_t address, bool* dest, uint16_t count, mb_time_t everyMs);
size_t watchFloat(uint8_t unit, uint16_t address, float* dest, mb_time_t everyMs,
ModbusOrder order = ModbusOrder::ABCD);
ModbusQuality quality(size_t watchHandle) const;
mb_time_t age(size_t watchHandle) const; // ms since it last arrived
bool allGood() const;
void deadband(size_t watchHandle, uint16_t amount);
void stopWatching(size_t watchHandle);
Each returns a handle. The point is not saving you a timer — it is quality():
Good |
read successfully within the last two intervals |
Uncertain |
between two and four intervals old, or the last read failed once |
Bad |
more than four intervals old, or the last two reads failed |
Unknown |
never successfully read at all |
A watch handle counts from 0, so 0 is a valid handle. A refused watch (no memory,
a bad argument) returns SIZE_MAX.
A deadband is how much a value must move before onChanged() fires. Without
one, an analogue reading dithering in its last bit is an event on every read.
A device that stops answering is polled with backoff rather than at full rate, so
one dead unit does not consume the segment: the interval doubles with each
consecutive failure, up to 16 times the one you asked for, and snaps back on the
first answer.
Reaching the poller directly
The facade covers the common shapes. poller() has the rest, and a binding that
works its element type out at run time needs all of it:
mb.poller().addRegisters(unit, address, uint16_t* dest, count, intervalMs, table, stride);
mb.poller().addBits (unit, address, bool* dest, count, intervalMs, table, stride);
mb.poller().addFloat32 (unit, address, float* dest, intervalMs, order);
mb.poller().addInt32 (unit, address, int32_t* dest, intervalMs, order);
mb.poller().addWide (unit, address, void* dest, ModbusWideType, count,
intervalMs, order, table, stride);
mb.poller().remove(index); // stop polling it
mb.poller().resume(index); // start again, in place, and read at once
mb.poller().isActive(index);
mb.poller().setInterval(index, ms);
mb.poller().lastResult(index);
mb.poller().consecutiveFailures(index);
mb.poller().refreshAll(); // everything due now: after a reconnect, say
mb.poller().setFailureBackoff(16); // how far a failing entry may stretch; 1 = never
mb.poller().onPoll(handler); // every completion, failures included
onPoll() takes a ModbusPollHandler, void(size_t index, ModbusResult result),
for a caller that wants to see each failure as it happens rather than only the
quality it leads to.
stride is the gap in the caller's layout, in bytes, exactly as for the wide
arrays above — a polled block that assumed its elements were adjacent would fill
every other slot with a plausible number and leave the rest untouched, which
reads as a process fault rather than as a binding fault.
remove() and resume() are a pair, and the reason resume() exists is that
stopping and starting a watch must not add a table entry each time. A program
that watches a device only in one machine state would otherwise grow its poll
table for the life of the program. resume() also makes the entry due
immediately: a watch you have just started should read now.
mb.poller().retarget(index, unit, address, table, order);
Points an entry somewhere else, in place: another unit, address, table or word
order, into the same variable. Nothing learned about the old address carries
over - quality goes back to Unknown - and a reply still on its way for the old
address is discarded when it concludes. For a watch whose address comes from
settings.
Points defined by settings
size_t first = mb.points().add(16); // a run of points, owned by the node
ModbusPoint* p = mb.points().at(first);
ModbusPointConfig c;
c.unit = 3; c.fc = ModbusPointFc::ReadHolding; c.address = 4100;
c.type = ModbusPointType::Float32; c.order = ModbusOrder::CDAB;
c.scale = 1.0f; c.everyMs = 2000; c.staleMs = 0; // 0: 3 x every
c.timeoutMs = 3000; // this unit's timeout (a radio hop)
p->configure(c); // every scan: cheap when unchanged
p->value(); p->ok(mb_millis()); p->quality(mb_millis()); p->age(mb_millis());
p->setWriteValue(v); // write points (FC05 / FC06 / FC16)
A ModbusPoint is one value described entirely by data - unit, function code,
address, type (Bit, Int16, UInt16, Int32, UInt32, Float32,
RegisterBit), word order, bit, scale, offset, interval, staleness, timeout -
read and decoded, or encoded and written. configure() with a different
target is a different point: quality Unknown, the old value dropped, an
answer in flight discarded. The node services every point from poll();
the set owns them, so a point outlives every operation it queued.
Quality: Unknown before the first answer, Good within two intervals,
Uncertain after one failure or once late, Bad after two failures or
staleMs without a good answer — and the value is held, never zeroed. A
failing point backs off, doubling its interval up to eight times, but is never
left longer than a minute between attempts unless its own interval is longer.
configure() returns false for a combination that cannot work: a 32-bit type
on FC06, a register bit on a write, a bit above 15.
mb_pointDecode() / mb_pointEncode() / mb_pointValid() are the codec on
their own.
Being told things
void onCoilChanged(uint16_t address, ModbusChangeHandler handler);
void onHregChanged(uint16_t address, ModbusChangeHandler handler);
void onHregChangedRange(uint16_t address, uint16_t count, ModbusChangeHandler handler);
void onChanged(ModbusPollChangeHandler handler);
void onEvent(ModbusEventHandler handler, void* context = nullptr);
typedef ModbusCallback<void(ModbusTable table, uint16_t address,
uint16_t value, uint8_t unit)> ModbusChangeHandler;
typedef ModbusCallback<void(size_t watchHandle, uint16_t address)> ModbusPollChangeHandler;
typedef void (*ModbusEventHandler)(void* context, ModbusEvent event,
uint8_t unit, const char* detail);
In a change handler, unit is the unit id the request was addressed to.
onChanged() reports the first address of the watch that changed.
A change handler fires once the whole request has been applied, so you never
see a half-written block, and only when the value actually changed.
onEvent() reports what is not one request's own outcome: LinkUp, LinkDown,
ChecksumError, FramingError, NoiseDiscarded, RequestTimeout,
ExceptionReceived, UnexpectedResponse, RequestRefused, FailsafeApplied,
Reconnecting. mb_eventName() turns one into text.
Safety, identity and access
void failsafeCoil(uint16_t address, bool safeValue);
void failsafeHreg(uint16_t address, uint16_t safeValue);
void failsafeAfter(mb_time_t ms); // 0 - the default - never applies them
void onFailsafe(ModbusFailsafeHandler handler);
void identity(const char* vendor, const char* product, const char* revision);
void onRequest(ModbusRequestFilter filter);
Addresses with no failsafe set hold their last value, because driving an
output is an action and doing it unasked is its own hazard.
onFailsafe is deliberately not the change handler: that one reports a client
acting, this one reports you acting because a client stopped. On a TCP
server the silence that counts is every client's, not one.
typedef ModbusCallback<void(ModbusTable table, uint16_t address, uint16_t safeValue)>
ModbusFailsafeHandler;
typedef ModbusCallback<uint8_t(uint8_t unit, const ModbusRequest& request)>
ModbusRequestFilter;
identity() sets the vendor, product and revision strings a client reads with
Read Device Identification (0x2B / 0x0E). It may be called before or after
begin().
Return an MB_EX_* code from the filter to refuse a request, or MB_EX_NONE to
let it through. request.function, request.address and request.quantity
say what was asked; mb_isWriteFunction(request.function) picks out the
writes. This is where a panel in local mode refuses writes, and the only place
it can be said before the write has already happened.
Diagnostics and tuning
ModbusRequestState status(uint8_t unit, uint16_t address, ModbusResult& outcome) const;
size_t pending() const;
bool idle() const;
void cancelAll(); // concludes everything as Cancelled
mb_time_t roundTrip(uint8_t unit) const;
mb_time_t silenceFor(uint8_t unit) const; // ms since it last answered, or MB_NEVER_HEARD
const char* framingReason() const; // how this link finds frames, and why
const char* lastError() const; // why begin(), connect() or listen() failed
size_t clients() const; // connections held, for a TCP server
ModbusLinkStats stats() const;
void resetStats();
void onTrace(ModbusLink::TraceHandler handler, void* context = nullptr);
void timeout(mb_time_t ms); // leave alone: it is measured per device
void retries(uint8_t count); // attempts after the first
void queueSize(size_t requests, size_t payloadBytes = 0);
// requests that may wait, and the bytes their
// values share; 0 = 16 / 1 KB board, 64 / 16 KB host.
// Taken when the link opens; too much for the
// board fails the open: "out of memory for the
// request queue" on lastError()
mb.client().setUnitTimeout(unit, ms); // one unit's own timeout; 0 = measured
setUnitTimeout() pins one unit's response timeout whatever is measured or
configured: a device behind a radio mesh answers in seconds, and a measured
timeout would never get its first sample (the cold-start figure is about
200 ms) or would collapse after one quick reply. The airtime of each exchange is
still added on top. Points set it for you from their timeoutMs.
struct ModbusLinkStats {
uint32_t framesReceived, framesSent, sendFailures;
uint32_t checksumErrors; // noise, or a baud rate mismatch
uint32_t framingErrors; // structurally impossible frames
uint32_t overruns; // longer than any valid ADU
uint32_t noiseBytes; // discarded while hunting for a frame start
uint32_t protocolErrors; // MBAP protocol id wrong, and the like
uint32_t echoesSuppressed; // our own transmission heard back (normal on some wiring)
};
typedef void (*TraceHandler)(void* context, bool transmitted,
const uint8_t* bytes, size_t length);
The difference between those counters is what tells you whether the device is
absent, the wiring is wrong or the baud rate is wrong — none of which looks
different from the application's point of view.
silenceFor() answers "is that device there" from traffic the application was
making anyway, so asking costs nothing. MB_NEVER_HEARD is not a long
silence — it means the unit has never answered at all, which is a wrong
address or a device never fitted, where a growing silence is a cable or a unit
that has died. Two different faults, two different ends of the plant.
lastError() is the operating system's own words — "No such file or
directory", "Address already in use", "Permission denied". A bool says
the port did not open; a code cannot say which of forty things went wrong at a
socket, and "Permission denied" on a serial device is a user not in the
dialout group, which no status code will ever tell you. Empty when nothing has
failed, and empty on Arduino, where opening a Stream the sketch already opened
cannot fail this way.
lastError(), framingReason() and connected() are virtual on
ModbusAPI, because a caller holding the base class cannot know which kind of
link it has — and those are exactly the questions such a caller needs to ask.
clients() works through the base class too, and is zero for anything that is
not a TCP server.
timeout() pins one figure for everything. Left alone, the timeout is measured
per device and includes the airtime the medium physically needs, so a slow drive
and a fast I/O block on one bus each get the figure they need.
Modbus TCP allows several requests in flight at once, matched by transaction id.
The library sends one at a time unless told otherwise, because many devices
cannot cope with more. Once a TCP client is connected,
mb.client().config().maxInFlight = 4; raises it for a device you have tested.
On RTU and ASCII leave it at one: serial Modbus has no transaction id, so two
answers in flight could not be told apart.
The bridge
TCP in front, a serial segment behind, routed by unit id.
ModbusBridge bridge;
bridge.serial("/dev/ttyUSB0", 19200); // the bus behind
bridge.listen(502); // the clients in front
bridge.route(1, 32); // who is out there
bridge.begin();
while (running) bridge.poll();
void serial(const char* device, uint32_t baud, ModbusParity parity = ModbusParity::Even); // host
void serial(Stream* stream, uint32_t baud); // Arduino
void serial(ModbusTransport& transport, uint32_t baudForTiming = 0);
void directionPin(int pin, bool activeHigh = true, uint32_t holdUs = 2000); // Arduino
void listen(uint16_t port = MODBUS_TCP_PORT); // host
void listen(ModbusListener& listener);
bool begin(); // opens both ends; false if either will not open
void poll();
void end();
void route(uint8_t unit);
void route(uint8_t first, uint8_t last);
void routeEverythingElse(bool enabled = true);
bool addCoils(uint8_t unit, uint16_t address, bool* array, uint16_t count);
bool addIsts (uint8_t unit, uint16_t address, bool* array, uint16_t count);
bool addHregs(uint8_t unit, uint16_t address, uint16_t* array, uint16_t count);
bool addIregs(uint8_t unit, uint16_t address, uint16_t* array, uint16_t count);
void identity(const char* vendor, const char* product, const char* revision);
void onEvent(ModbusEventHandler handler, void* context = nullptr);
void maxClients(size_t limit);
void maxOutstanding(size_t limit);
size_t clients() const;
uint32_t forwarded() const; // requests sent to the field bus
uint32_t servedLocally() const; // answered by the bridge itself
uint32_t noPath() const; // refused 0x0A
uint32_t deviceAbsent() const; // refused 0x0B
const char* framingReason() const; // the field bus's RTU framing, and why
void forceFraming(ModbusFraming framing);
ModbusGateway& gateway(); // the engine underneath
ModbusDataMap& map(uint8_t unit); // one unit's local register map
The bridge does not decode what it carries: a device behind it may support
function codes this library does not, so requests for a routed unit are forwarded
byte for byte.
maxClients() (8 by default) is how many TCP connections it holds;
maxOutstanding() (8 by default) is how many of their requests may wait for the
one serial bus at once. routeEverythingElse() forwards any unit id that is not
routed and not served locally, for a bus whose devices you do not know in
advance; it is off by default, because a mistyped unit id then becomes a timeout
instead of an immediate 0x0A.
Its own registers live at a unit id of their own — MB_UNIT_SELF (255) by
convention — so a client can ask the bridge about itself without a request
reaching the field bus. A local binding always wins over a route.
Two failures a direct connection cannot report:
| 0x0A Gateway Path Unavailable | no route, or no room. A problem with the bridge. |
| 0x0B Gateway Target Failed | the route was there and the device did not answer. A problem with the device. |
Worth wiring into an alarm separately. A plain timeout cannot tell an engineer
which end of the plant to walk to.
Reaching past the facade
ModbusClient& client();
ModbusServer& server(); // the serial server (RTU and ASCII)
ModbusPoller& poller();
ModbusPointSet& points();
ModbusLink& link();
ModbusDataMap& map(); // the map the node serves, serial or TCP
These are the real objects. The facade composes them; it does not wrap them, and
using both together is normal. Reach for them the day you need something the
short form does not offer — diagnostics sub-functions, raw requests, FIFO
queues, per-unit maps — without rewriting what you already have.
server(), map() and points() create what they return the first time they
are called, so a node that only asks never pays for a server. On a TCP server,
map() is the map every connection shares; server() is the serial server and
is not used by listen().
Two things only reachable this way:
// A serial node that answers more than one unit id, each with its own registers.
mb.server().mapFor(18).bindBlock(ModbusTable::HoldingRegister, 0, otherRegisters, 10);
// A function code the library does not serve generically: 0x14 / 0x15 file
// records, 0x18 FIFO queues, or a vendor code.
mb.server().engine().setFunctionHandler(0x41, handler); // a ModbusFunctionHandler
The default map (map()) serves the unit id given to slave(); each
mapFor(unit) adds another unit id the node answers, with registers of its own. A function handler writes the response body after the
function code into out and returns its length, or returns 0 with exception
set to refuse; see ModbusServerEngine.h.
Types and enumerations
ModbusResult
What happened to an operation.
Queued |
accepted; the outcome arrives on a later poll() |
Ok |
it completed. This is what success looks like |
NotInitialised |
begin() has not been called |
InvalidArgument |
null pointer, zero count, or a quantity Modbus disallows |
NotMapped |
the address cannot be served: nothing bound locally, or a range no device could hold (one that would run past 65535) |
Duplicate |
an identical operation is already pending |
QueueFull |
the queue is at capacity |
UnknownUnit |
the destination is not reachable |
Superseded |
a newer write to the same address replaced it before it went out |
Cancelled |
discarded by cancelAll() or a restart |
Timeout |
no response after every retry |
Refused |
the server raised an exception |
Acknowledged |
exception 0x05: accepted, and it will take a while. Poll, do not retry. |
Busy |
exception 0x06: retry later |
GatewayPathUnavailable |
exception 0x0A |
GatewayTargetFailed |
exception 0x0B |
LinkDown |
the transport is not usable |
ChecksumError |
a reply arrived and was corrupt |
ProtocolError |
a reply arrived and made no sense |
bool mb_accepted(ModbusResult); // queued or completed
bool mb_succeeded(ModbusResult); // completed and did what was asked
bool mb_worthRetrying(ModbusResult); // notably NOT Acknowledged
const char* mb_resultName(ModbusResult);
Acknowledged and Busy are both "not yet", and the difference is the whole of
what you do next: one means the server took the job and is still doing it —
retrying there sends a second copy of a command already running.
ModbusRequestState
Returned by status(), for a caller that would rather ask than be told.
Idle · Pending · Done · Failed
ModbusQuality
Unknown · Good · Uncertain · Bad — see Watching.
mb_qualityName() turns one into text.
ModbusOrder
ABCD |
big-endian throughout, high word first. What the specification implies. |
CDAB |
low word first, big-endian within each word. Extremely common. |
BADC |
high word first, byte-swapped within each word. Rare. |
DCBA |
fully reversed — a little-endian CPU's memory, copied straight out. |
ModbusTable
Coil · DiscreteInput · HoldingRegister · InputRegister
ModbusParity
Even |
8E1, the Modbus default |
Odd |
8O1 |
None |
8N2: the specification asks for two stop bits when there is no parity |
None8N1 |
8N1: off-specification, but common enough to support |
ModbusFraming
Auto (the default: chosen from the line rate, how often poll() runs, and
whether the node only answers or also asks) ·
Gap (the specification's t3.5 silence) · Length (worked out from the
function code). mb_framingName() turns one into text.
Function codes covered
| Code | Function | Client | Server |
|---|---|---|---|
| 0x01 | Read Coils | readCoil(), readCoils() |
built in |
| 0x02 | Read Discrete Inputs | readIsts() |
built in |
| 0x03 | Read Holding Registers | readHreg(), readHregs(), the wide reads |
built in |
| 0x04 | Read Input Registers | readIreg(), readIregs() |
built in |
| 0x05 | Write Single Coil | writeCoil() |
built in |
| 0x06 | Write Single Register | writeHreg() |
built in |
| 0x0F | Write Multiple Coils | writeCoils() |
built in |
| 0x10 | Write Multiple Registers | writeHregs(), the wide writes |
built in |
| 0x16 | Mask Write Register | maskHreg() |
built in |
| 0x17 | Read/Write Multiple Registers | readWriteHregs() |
built in |
| 0x2B / 0x0E | Read Device Identification | readDeviceId() |
built in, from identity() |
| 0x07 | Read Exception Status | client().readExceptionStatus() |
built in, serial only |
| 0x08 | Diagnostics | ping(), client().diagnostics() |
built in, serial only |
| 0x0B | Get Comm Event Counter | client().forward() |
built in, serial only |
| 0x0C | Get Comm Event Log | client().forward() |
built in, serial only |
| 0x11 | Report Server ID | client().reportServerId() |
built in, serial only |
| 0x18 | Read FIFO Queue | client().readFifoQueue() |
a function handler |
| 0x14 / 0x15 | Read / Write File Record | client().forward() |
a function handler |
The facade covers the ones an application uses every day. The rest are one step
past it, on client():
mb.client().readExceptionStatus(unit, uint8_t& dest, done);
mb.client().diagnostics(unit, subFunction, const uint8_t* data, dataLen,
uint8_t* dest, destCapacity, done);
mb.client().reportServerId(unit, uint8_t* dest, destCapacity, done);
mb.client().readDeviceIdentification(unit, readDeviceIdCode, objectId,
uint8_t* dest, destCapacity, done);
mb.client().readFifoQueue(unit, pointerAddress, uint16_t* dest, maxValues, done);
mb.client().forward(unit, const uint8_t* pdu, pduLen, onResponse); // any PDU at all
forward() sends a request PDU byte for byte and hands the raw response to a
ModbusPassthroughHandler, void(ModbusResult, const uint8_t* pdu, uint16_t pduLen).
pdu is valid only during the call, and is null when nothing came back.
0x07, 0x08, 0x0B, 0x0C and 0x11 are serial-line only in the specification,
so a TCP server built with this library refuses them with Illegal Function.
0x14, 0x15 and 0x18 address a file store or a queue that belongs to your
application, so the server answers them only through a function handler you
register (see Reaching past the facade).
Build-time settings
Defaults chosen so that nothing needs setting. Define one before the library is
compiled (a -D compiler flag, or target_compile_definitions() in CMake) only
when a project needs more than the default:
| Macro | Board | Host | |
|---|---|---|---|
MODBUS_QUEUE_CAPACITY |
16 | 64 | requests that may wait at once, per node |
MODBUS_PAYLOAD_SPACE |
1024 | 64 × 253 | bytes the queued requests' values share |
MODBUS_MAX_UNITS |
16 | 248 | units a client keeps its own timing for; past that, the unit heard from longest ago gives up its row |
queueSize() changes the first two per node at run time, which is usually the
better choice.
The library reads time only through mb_millis() and mb_micros(). On a target
with its own clock (an RTOS tick, a hardware timer), install it with
mb_setClockSource(millisFn, microsFn); mb_resetClockSource() puts the
platform clock back.