Skip to content

Usage guide

Two layers

Layer Header Use it when
Low-level core slmp_minimal.h You want fixed caller-owned buffers, explicit slmp::DeviceAddress values, and direct sync or async SLMP calls.
High-level facade slmp_high_level.h You want string addresses, typed values, named snapshots, and reusable polling plans.

The high-level layer is optional. Include slmp_high_level.h explicitly when you use helpers such as slmp::highlevel::readTyped.

Every individual bit-write entry uses C++ bool, including direct, extended, random, typed, named, and bit-in-word operations. No numeric or string compatibility overload is provided. Packed bit-block words are a distinct wire-level input and remain uint16_t values.

Semantic bit APIs accept only bit-addressable device families such as M, X, Y, and B. Word families such as D, W, R, ZR, G, and HG are rejected before request construction. Conversely, typed and named numeric or string values require word-addressable families. Use an explicit low-level word API when intentionally reading 16 packed bit-device states, and use .n or writeBitInWord for one bit inside a word device. The library never changes an invalid bit-unit call into a word mask or read-modify-write operation.

Every device span must fit the address namespace selected by the active wire format. Legacy/Q-L device specifications end at 0xFFFFFF; iQ-R device specifications end at 0xFFFFFFFF; link-direct device specifications always use the Q/L layout and therefore always end at 0xFFFFFF. An ordinary word-device word and a direct bit consume one device number per point; an ordinary DWord or float32 consumes two. Word-unit access to a bit device consumes sixteen bit numbers per wire word, its DWord form consumes thirty-two, and one packed bit-block point also consumes sixteen. Native long/DWord device families use their protocol-defined logical width—for example, four LTN/LSTN current-value words represent one logical timer. A range crossing the wire ceiling returns InvalidArgument before request construction or transport activity. This is only a wire-representability check: the library does not use a PLC profile's practical device-range catalog as a pre-send address guard.

Setup

This complete TCP setup creates the transport, buffers, slmp::SlmpClient, and profile configuration. For UDP transport, keep the same host 192.168.250.100 and use UDP port 1035. ArduinoUdpTransport requires a numeric remote IP address and discards every datagram whose source IP address or port differs from that configured endpoint.

#include <Arduino.h>
#include <WiFi.h>

#include <slmp_arduino_transport.h>
#include <slmp_high_level.h>
#include <slmp_minimal.h>

constexpr char kWifiSsid[] = "YOUR_WIFI_SSID";
constexpr char kWifiPassword[] = "YOUR_WIFI_PASSWORD";
constexpr char kPlcHost[] = "192.168.250.100";
constexpr uint16_t kTcpPort = 1025;
constexpr auto kProfile = slmp::highlevel::PlcProfile::IqR;

WiFiClient tcp;
slmp::ArduinoClientTransport transport(
    tcp,
    slmp::configureEsp32WifiClientKeepAlive,
    slmp::writeEsp32WifiClientNonBlocking);

uint8_t txBuffer[160] = {};
uint8_t rxBuffer[160] = {};
slmp::SlmpClient plc(transport, kProfile, slmp::TargetAddress{0x00, 0xFF, slmp::module_io::OwnStation, 0x00}, txBuffer, sizeof(txBuffer), rxBuffer, sizeof(rxBuffer));

void setup() {
    Serial.begin(115200);
    WiFi.begin(kWifiSsid, kWifiPassword);
    while (WiFi.status() != WL_CONNECTED) {
        delay(250);
    }
    if (plc.connect(kPlcHost, kTcpPort)) {
        Serial.println("PLC connected");
    }
}

void loop() {
    delay(1000);
}

The TCP adapter requires a keepalive configurator and an explicit non-blocking transmit policy. On ESP32, use the supplied helpers shown above: the transmit helper performs one MSG_DONTWAIT socket send without waiting or retrying. For another Arduino stack, writeArduinoClientWhenReady calls Client::write() only after availableForWrite() reports positive immediate capacity and caps the call to that capacity. Do not use it when that platform cannot guarantee the readiness meaning. Missing policies and missing or failed keepalive setup make the connection fail.

Routing / target station

Every client must receive a complete target when it is constructed. For a directly connected own station, specify all four values explicitly.

slmp::TargetAddress controls the SLMP destination header. It is not a device family selector; routed devices such as Un\Gn and Jn\... still need their own address syntax.

const slmp::TargetAddress target{
    0x01,
    0x02,
    slmp::module_io::OwnStation,
    0x00,
};
slmp::SlmpClient plc(transport, kProfile, target, txBuffer, sizeof(txBuffer), rxBuffer, sizeof(rxBuffer));

Use slmp::module_io constants such as slmp::module_io::MultipleCpu2 when routing to multi-CPU targets. The target is fixed for the client lifetime.

For iQ-R multi-CPU U3En\HG... access, the qualified device never changes the request target. Construct a separate client with the destination CPU target when a write must be reflected there. A write can return a normal end code without changing the intended CPU buffer when the selected request target identifies a different CPU or Own Station. Cross-CPU reads remain valid. See the shared iQ-R target guidance.

Extended device access

G, HG, and J devices are not normal standalone addresses. The C++ high-level facade is for normal device strings such as D100:U; use the low-level slmp::SlmpClient APIs for extended device routes.

Address form Low-level call shape
U3\G100 readWordsModuleBuf(3, false, 100, ...)
U3E0\HG0 readWordsModuleBuf(0x03E0, true, 0, ...)
J2\SW10 readWordsLinkDirect(2, slmp::DeviceCode::SW, 0x10, ...)
J1\X10 readBitsLinkDirect(1, slmp::DeviceCode::X, 0x10, ...)

The selected PLC profile and the actual PLC configuration still decide whether the route is accepted.

Module-buffer writes change configured module state. Run this example only on a controlled test PLC and a buffer range that the module documentation identifies as safe to modify. Save the original words first. If a write or restoration returns OutcomeUnknown, do not resend it automatically; reconnect, inspect the module state, and reconcile it under an explicit test policy.

uint16_t originalModuleWords[4] = {};
const slmp::Error moduleReadErr =
    plc.readWordsModuleBuf(3, false, 100, 4, originalModuleWords, 4);
if (moduleReadErr == slmp::Error::Ok) {
    const uint16_t moduleWrite[] = {1, 2, 3, 4};
    const slmp::Error moduleWriteErr =
        plc.writeWordsModuleBuf(3, false, 100, moduleWrite, 4);
    if (moduleWriteErr == slmp::Error::Ok) {
        const slmp::Error restoreErr =
            plc.writeWordsModuleBuf(3, false, 100, originalModuleWords, 4);
        if (restoreErr == slmp::Error::OutcomeUnknown) {
            // The restore may have taken effect. Inspect and reconcile the buffer manually.
        } else if (restoreErr != slmp::Error::Ok) {
            // Restoration failed. Inspect and reconcile the buffer manually.
        }
    } else if (moduleWriteErr == slmp::Error::OutcomeUnknown) {
        // The test values may be present. Inspect state; do not retry or restore blindly.
    } else {
        // Report the confirmed write failure.
    }
} else {
    // Report the original-value read failure; no write was attempted.
}

uint16_t extendUnitWords[2] = {};
const slmp::Error extendReadErr =
    plc.readExtendUnitWords(0, 2, slmp::module_io::MultipleCpu1, extendUnitWords, 2);
if (extendReadErr != slmp::Error::Ok) {
    // Report the read failure.
}

uint16_t linkWords[1] = {};
const slmp::Error linkWordReadErr =
    plc.readWordsLinkDirect(2, slmp::DeviceCode::SW, 0x10, 1, linkWords, 1);
if (linkWordReadErr != slmp::Error::Ok) {
    // Report the read failure.
}

bool linkBits[16] = {};
const slmp::Error linkBitReadErr =
    plc.readBitsLinkDirect(1, slmp::DeviceCode::X, 0x10, 16, linkBits, 16);
if (linkBitReadErr != slmp::Error::Ok) {
    // Report the read failure.
}

For extended random access, build slmp::ExtDeviceSpec entries:

const slmp::ExtDeviceSpec wordDevices[] = {
    slmp::ExtDeviceSpec::moduleBuf(3, false, 100),
    slmp::ExtDeviceSpec::linkDirect(2, slmp::DeviceCode::SW, 0x10),
};
uint16_t values[2] = {};
const slmp::Error randomExtReadErr =
    plc.readRandomExt(wordDevices, 2, values, 2, nullptr, 0, nullptr, 0);
if (randomExtReadErr != slmp::Error::Ok) {
    // Report the read failure.
}

Extended random writes reject duplicate or overlapping destinations within one request. Module slot or link-network identity is part of the destination, so the same numeric device on two different qualified routes remains distinct.

Monitor, self-test, and Clear Error

Monitor registration and each cycle are separate one-request operations. Pass the registered Word and DWord counts to every cycle; the client does not auto-register, retry, or infer them. Calling a cycle before PLC registration sends one cycle request and returns the PLC error. The combined expected count must be nonzero and cannot exceed the selected profile's monitor-registration limit.

Monitor registration and Clear Error change PLC state. Run them only on a controlled test PLC. Clear Error clears the current PLC error indication, so record the diagnostic state first and check its result explicitly. An OutcomeUnknown result requires manual state inspection and must not be retried automatically.

const slmp::DeviceAddress words[] = {slmp::dev::D(kProfile, slmp::dev::dec(120))};
const slmp::DeviceAddress dwords[] = {slmp::dev::D(kProfile, slmp::dev::dec(200))};
const slmp::Error registerErr = plc.registerMonitorDevices(words, 1, dwords, 1);
if (registerErr == slmp::Error::Ok) {
    uint16_t wordValues[1] = {};
    uint32_t dwordValues[1] = {};
    const slmp::Error cycleErr =
        plc.runMonitorCycle(wordValues, 1, 1, dwordValues, 1, 1);
    if (cycleErr != slmp::Error::Ok) {
        // Report the monitor-cycle failure before issuing another command.
    }
} else {
    // Report registration failure. Do not run a cycle for an unconfirmed registration.
}

const uint8_t testData[] = {'A', '1', 'B', '2', 'C', '3', 'D', '4'};
uint8_t echo[sizeof(testData)] = {};
size_t echoLength = 0;
const slmp::Error selfTestErr =
    plc.selfTestLoopback(testData, sizeof(testData), echo, sizeof(echo), echoLength);
if (selfTestErr != slmp::Error::Ok) {
    // Report the self-test failure before issuing another command.
}

// Opt in only after recording the PLC error state on a controlled test PLC.
const bool clearErrorApproved = false;
if (clearErrorApproved) {
    const slmp::Error clearErr = plc.clearError();
    if (clearErr != slmp::Error::Ok) {
        // Surface the result. OutcomeUnknown requires manual state reconciliation.
    }
}

Self-test accepts only 1–960 ASCII 0-9/A-F bytes and succeeds only when the declared length, actual length, and echo match exactly. Clear Error always uses the fixed empty-payload command.

remoteReset() returns after the fixed RESET frame has been transmitted and then closes the transport. Reconnect explicitly before another request and verify PLC state when the application requires confirmation that RESET occurred.

Strict profile

SlmpClient enables strict profile checks by default. With a selected profile, operations known to be unavailable for that PLC are rejected before sending.

Profile guard bypass is not part of the normal public API. Profile evidence collection uses separate maintainer tooling.

Remote password

Remote password lock/unlock commands are available on the low-level slmp::SlmpClient. The C++ high-level facade does not automatically unlock or lock a remote password. Run this sequence only against a controlled test PLC whose password and recovery route are known. If your PLC route uses remote password protection, unlock after connecting and lock before closing. Always surface the re-lock result. If unlock or re-lock returns OutcomeUnknown, do not resend it automatically: reconnect through the approved route, inspect the PLC lock state, and reconcile it explicitly because access may remain unlocked.

const slmp::Error unlockErr = plc.remotePasswordUnlock("secret");
if (unlockErr == slmp::Error::Ok) {
    slmp::highlevel::Value value;
    const slmp::Error readErr = slmp::highlevel::readTyped(plc, "D100:U", value);
    if (readErr != slmp::Error::Ok) {
        // Report the read failure, but still attempt to restore the lock state.
    }

    const slmp::Error lockErr = plc.remotePasswordLock("secret");
    if (lockErr != slmp::Error::Ok) {
        // Surface this result. OutcomeUnknown may mean access remains unlocked.
    }
} else if (unlockErr == slmp::Error::OutcomeUnknown) {
    // The lock state is unknown. Inspect and reconcile it; do not retry blindly.
} else {
    // Report the confirmed unlock failure.
}

For C200-series password end codes, see the shared SLMP Troubleshooting & Codes page.

SLMP response end codes

When the PLC returns a non-zero SLMP end code, low-level calls return slmp::Error::PlcError. Read lastEndCode() for the PLC response code and lastErrorInfo() when the PLC returned the structured error-information block.

When that block contains at least its structured nine bytes, its network, station, module I/O, multidrop station, command, and subcommand must identify the active request. A mismatch is a malformed response: reads return slmp::Error::ProtocolError, while a possibly transmitted state-changing request returns slmp::Error::OutcomeUnknown with cause slmp::Error::ProtocolError. The transport generation is closed and no retry or route fallback is performed. Matching error information may contain trailing bytes; the complete frame remains available through lastResponseFrame(). Responses with fewer than nine error-information bytes retain their existing behavior.

slmp::highlevel::Value value;
const slmp::Error err = slmp::highlevel::readTyped(plc, "D100:U", value);
if (err == slmp::Error::PlcError) {
    Serial.printf("SLMP end_code=0x%04X\n", plc.lastEndCode());
    if (plc.hasLastErrorInfo()) {
        const slmp::SlmpErrorInfo& info = plc.lastErrorInfo();
        Serial.printf("command=0x%04X\n", info.command);
        Serial.printf("subcommand=0x%04X\n", info.subcommand);
    }
}

Timeout, close, and unknown write outcomes

The transaction timeout is one absolute deadline starting immediately before the first transport write() attempt. Time spent after begin* and before the first update() therefore does not consume the transaction budget. Partial writes, response framing, discarded foreign routes or 4E serials, body reads, and decoding do not restart it. Expiration closes the transport generation. A later operation requires an explicit successful reconnect.

Configure this deadline with setTimeoutMs() only while the client is idle. Every non-zero uint32_t value is accepted while idle and applies to later requests; zero is slmp::Error::InvalidArgument. While any request is active, valid values and zero both return slmp::Error::Busy without changing timeoutMs(), the request state, or its deadline. The active request snapshots the configured value at its first transport write attempt; exact-boundary and 32-bit clock-wrap behavior are therefore unaffected by later configuration attempts.

connect() is a separate explicit operation. Its blocking bound and failure behavior belong to the selected ITransport; setTimeoutMs() controls the SLMP transaction after connection, not a portable connection deadline.

Synchronous request methods use one library-owned wait loop. After update() leaves an active request without sending, receiving, completing, or failing, the loop calls ITransport::cooperativeWait(maximum_wait_ms). The supplied bound is 0 for the first no-progress observation, requesting a scheduler yield without an intentional time sleep. A consecutive no-progress observation uses at most 1 ms, and any observed progress resets the phase to yield-first. Every positive bound remains no greater than the remaining transaction deadline. A custom transport must implement this required method by yielding to its scheduler/network stack or waiting for relevant I/O readiness, may return early, and must not send, receive, retry, complete, or otherwise mutate the active SLMP request. An empty no-op implementation that permits an unbounded tight spin is not supported.

Asynchronous begin*/update() operation remains entirely caller-driven and never invokes cooperativeWait(). The application chooses its own task, event-loop, or scheduler cadence between update() calls.

A read that expires returns slmp::Error::Timeout. Closing an active read returns slmp::Error::Closed. If any byte of a state-changing request may have been sent and confirmation then fails, the result is slmp::Error::OutcomeUnknown; inspect lastOutcomeUnknownReason() for the machine-readable cause. The PLC may already contain the requested change, so never retry that operation automatically.

const slmp::Error err = plc.writeOneWord(
    slmp::dev::D(kProfile, slmp::dev::dec(100)), 1234);
if (err == slmp::Error::OutcomeUnknown) {
    const slmp::Error cause = plc.lastOutcomeUnknownReason();
    // Reconnect and inspect the controlled process/PLC state before deciding
    // whether another write is safe. Do not automatically resend here.
    (void)cause;
}

Only one request may be active. A second begin* call returns slmp::Error::Busy before changing the active request, frame, counters, output destination, or transport. High-level communicating helpers apply the same Busy-first rule. connect() likewise returns false, exposes slmp::Error::Busy through lastError(), and does not close or replace the active operation. Do not invoke one client from multiple threads; use a separate client, transport, and buffers per thread or task. Independent client instances can progress concurrently.

Fixed-buffer capacity contract

Every operation proves all fixed capacities before request encoding, 4E serial allocation, request-state publication, or transport activity. SLMP Ethernet does not use byte escaping, so the TX worst case is the complete frame: 15 + command payload bytes for 3E or 19 + command payload bytes for 4E, subject also to the protocol/profile limit and ITransport::maximumRequestFrameSize().

The RX requirement is the frame prefix (9 bytes for 3E or 13 for 4E), the two-byte end code, and the larger of the operation's maximum success data or the nine-byte PLC error-information area. Caller-owned output arrays have an independent element capacity. runMonitorCycle therefore requires explicit word and DWord output capacities, and readRandomLabels requires maximum_response_data_bytes because label value sizes are not derivable from the request. A capacity failure returns BufferTooSmall; a protocol, profile, point-count, or wire-field limit returns InvalidArgument. Neither result sends or partially publishes a request.

Read a single value

slmp::highlevel::readTyped reads one logical value from one address.

Address form Value type Field to read
D100:U slmp::highlevel::ValueType::U16 value.u16
D100:S slmp::highlevel::ValueType::S16 value.s16
D200:D slmp::highlevel::ValueType::U32 value.u32
D200:L slmp::highlevel::ValueType::S32 value.s32
D300:F slmp::highlevel::ValueType::Float32 value.f32
M1000:BIT or D50.3 slmp::highlevel::ValueType::Bit value.bit
#include <Arduino.h>
#include <WiFi.h>

#include <slmp_arduino_transport.h>
#include <slmp_high_level.h>
#include <slmp_minimal.h>

constexpr char kWifiSsid[] = "YOUR_WIFI_SSID";
constexpr char kWifiPassword[] = "YOUR_WIFI_PASSWORD";
constexpr char kPlcHost[] = "192.168.250.100";
constexpr uint16_t kTcpPort = 1025;
constexpr auto kProfile = slmp::highlevel::PlcProfile::IqR;

WiFiClient tcp;
slmp::ArduinoClientTransport transport(
    tcp,
    slmp::configureEsp32WifiClientKeepAlive,
    slmp::writeEsp32WifiClientNonBlocking);
uint8_t txBuffer[160] = {};
uint8_t rxBuffer[160] = {};
slmp::SlmpClient plc(transport, kProfile, slmp::TargetAddress{0x00, 0xFF, slmp::module_io::OwnStation, 0x00}, txBuffer, sizeof(txBuffer), rxBuffer, sizeof(rxBuffer));

void setup() {
    Serial.begin(115200);
    WiFi.begin(kWifiSsid, kWifiPassword);
    while (WiFi.status() != WL_CONNECTED) {
        delay(250);
    }
    plc.connect(kPlcHost, kTcpPort);
}

void loop() {
    slmp::highlevel::Value value;
    const slmp::Error err = slmp::highlevel::readTyped(plc, "D100:U", value);
    if (err == slmp::Error::Ok) {
        Serial.printf("D100=%u\n", static_cast<unsigned>(value.u16));
    } else {
        Serial.printf("readTyped failed: %s\n", slmp::errorString(err));
    }
    delay(1000);
}

Write a single value

slmp::highlevel::writeTyped writes one logical value. Run this only on a controlled test PLC and a reserved test address. The example saves the original value, attempts restoration after a confirmed write even if readback fails, and reports an unknown write or restoration outcome for manual reconciliation. It never retries or restores blindly after an outcome-unknown write.

#include <Arduino.h>
#include <WiFi.h>

#include <slmp_arduino_transport.h>
#include <slmp_high_level.h>
#include <slmp_minimal.h>

constexpr char kWifiSsid[] = "YOUR_WIFI_SSID";
constexpr char kWifiPassword[] = "YOUR_WIFI_PASSWORD";
constexpr char kPlcHost[] = "192.168.250.100";
constexpr uint16_t kTcpPort = 1025;
constexpr auto kProfile = slmp::highlevel::PlcProfile::IqR;

WiFiClient tcp;
slmp::ArduinoClientTransport transport(
    tcp,
    slmp::configureEsp32WifiClientKeepAlive,
    slmp::writeEsp32WifiClientNonBlocking);
uint8_t txBuffer[160] = {};
uint8_t rxBuffer[160] = {};
slmp::SlmpClient plc(transport, kProfile, slmp::TargetAddress{0x00, 0xFF, slmp::module_io::OwnStation, 0x00}, txBuffer, sizeof(txBuffer), rxBuffer, sizeof(rxBuffer));
bool writeAttempted = false;

void setup() {
    Serial.begin(115200);
    WiFi.begin(kWifiSsid, kWifiPassword);
    while (WiFi.status() != WL_CONNECTED) {
        delay(250);
    }
    plc.connect(kPlcHost, kTcpPort);
}

void loop() {
    if (!writeAttempted) {
        writeAttempted = true;
        slmp::highlevel::Value original;
        const slmp::Error originalReadErr =
            slmp::highlevel::readTyped(plc, "D9000:U", original);
        if (originalReadErr != slmp::Error::Ok) {
            Serial.printf("original read failed: %s\n", slmp::errorString(originalReadErr));
        } else {
            const slmp::Error writeErr = slmp::highlevel::writeTyped(
                plc,
                "D9000:U",
                slmp::highlevel::Value::u16Value(321U));
            Serial.printf("write D9000: %s\n", slmp::errorString(writeErr));
            if (writeErr == slmp::Error::Ok) {
                slmp::highlevel::Value readback;
                const slmp::Error readbackErr =
                    slmp::highlevel::readTyped(plc, "D9000:U", readback);
                const slmp::Error restoreErr =
                    slmp::highlevel::writeTyped(plc, "D9000:U", original);
                if (restoreErr == slmp::Error::OutcomeUnknown) {
                    Serial.println("restore outcome unknown; inspect D9000 manually");
                } else if (restoreErr != slmp::Error::Ok) {
                    Serial.println("restore failed; inspect and reconcile D9000 manually");
                }
                if (readbackErr != slmp::Error::Ok) {
                    Serial.printf("readback failed: %s\n", slmp::errorString(readbackErr));
                }
            } else if (writeErr == slmp::Error::OutcomeUnknown) {
                Serial.println("write outcome unknown; do not retry or restore blindly");
            }
        }
    }
    delay(1000);
}

Named snapshot

slmp::highlevel::readNamed reads mixed addresses in caller order using one random-read request. Plans containing fallback or long-timer routes are rejected during plan compilation, before transport. Use readTyped or the dedicated long-timer helpers for those route-specific scalar reads. LCN current values use readTyped/Random DWord, while LCS and LCC states use readTyped/Direct bit access. slmp::highlevel::writeNamed sends one random word/DWord request or one random-bit request; mixed families and bit-in-word read-modify-write are rejected. Executing a hand-built ReadPlan verifies that every entry appears in the corresponding word or DWord batch. A missing batch key is an error; the library does not invent a zero value for a device that was not read.

The write portion below is for a controlled test PLC and reserved test devices only. It saves the original values and restores them after a confirmed write. If either state-changing request returns OutcomeUnknown, it stops without an automatic retry; reconnect and reconcile the affected devices manually.

#include <Arduino.h>
#include <WiFi.h>

#include <string>
#include <vector>

#include <slmp_arduino_transport.h>
#include <slmp_high_level.h>
#include <slmp_minimal.h>

constexpr char kWifiSsid[] = "YOUR_WIFI_SSID";
constexpr char kWifiPassword[] = "YOUR_WIFI_PASSWORD";
constexpr char kPlcHost[] = "192.168.250.100";
constexpr uint16_t kTcpPort = 1025;
constexpr auto kProfile = slmp::highlevel::PlcProfile::IqR;

WiFiClient tcp;
slmp::ArduinoClientTransport transport(
    tcp,
    slmp::configureEsp32WifiClientKeepAlive,
    slmp::writeEsp32WifiClientNonBlocking);
uint8_t txBuffer[192] = {};
uint8_t rxBuffer[192] = {};
slmp::SlmpClient plc(transport, kProfile, slmp::TargetAddress{0x00, 0xFF, slmp::module_io::OwnStation, 0x00}, txBuffer, sizeof(txBuffer), rxBuffer, sizeof(rxBuffer));
bool writeAttempted = false;

void setup() {
    Serial.begin(115200);
    WiFi.begin(kWifiSsid, kWifiPassword);
    while (WiFi.status() != WL_CONNECTED) {
        delay(250);
    }
    plc.connect(kPlcHost, kTcpPort);
}

void loop() {
    if (!writeAttempted) {
        writeAttempted = true;
        const std::vector<std::string> writeAddresses = {
            "D9000:U", "D9002:S", "D9004:L", "D9008:F"
        };
        slmp::highlevel::Snapshot originalValues;
        const slmp::Error originalReadErr =
            slmp::highlevel::readNamed(plc, writeAddresses, originalValues);

        slmp::highlevel::Snapshot updates = {
            {"D9000:U", slmp::highlevel::Value::u16Value(100U)},
            {"D9002:S", slmp::highlevel::Value::s16Value(-10)},
            {"D9004:L", slmp::highlevel::Value::s32Value(-123456)},
            {"D9008:F", slmp::highlevel::Value::float32Value(12.5f)}
        };
        if (originalReadErr != slmp::Error::Ok) {
            Serial.printf("original read failed: %s\n", slmp::errorString(originalReadErr));
        } else {
            const slmp::Error writeErr = slmp::highlevel::writeNamed(plc, updates);
            Serial.printf("writeNamed: %s\n", slmp::errorString(writeErr));
            if (writeErr == slmp::Error::Ok) {
                const slmp::Error restoreErr =
                    slmp::highlevel::writeNamed(plc, originalValues);
                Serial.printf("restore: %s\n", slmp::errorString(restoreErr));
                if (restoreErr == slmp::Error::OutcomeUnknown) {
                    Serial.println("restore outcome unknown; inspect devices manually");
                } else if (restoreErr != slmp::Error::Ok) {
                    Serial.println("restore failed; inspect and reconcile devices manually");
                }
            } else if (writeErr == slmp::Error::OutcomeUnknown) {
                Serial.println("write outcome unknown; do not retry or restore blindly");
            }
        }
    }

    const std::vector<std::string> addresses = {
        "SM400:BIT",
        "D100:U",
        "D101:S",
        "D200:F",
        "D50.3"
    };

    slmp::highlevel::Snapshot snapshot;
    const slmp::Error readErr = slmp::highlevel::readNamed(plc, addresses, snapshot);
    if (readErr == slmp::Error::Ok && snapshot.size() == addresses.size()) {
        Serial.printf(
            "SM400:BIT=%u D100:U=%u D101:S=%d D200:F=%.3f D50.3=%u\n",
            snapshot[0].value.bit ? 1U : 0U,
            static_cast<unsigned>(snapshot[1].value.u16),
            static_cast<int>(snapshot[2].value.s16),
            static_cast<double>(snapshot[3].value.f32),
            snapshot[4].value.bit ? 1U : 0U);
    }

    delay(1000);
}

Block reads

Contiguous low-level reads are limited to one protocol request. Requests above the point limit fail; the application must explicitly issue and label multiple requests when different acquisition times are acceptable.

Polling

slmp::highlevel::Poller stores one compiled slmp::highlevel::ReadPlan, one validated Random Read payload, compact result indexes, and reusable value storage. Repeated reads therefore do not re-parse addresses, revalidate device spans, rebuild indexes, or re-encode device specifications. Each cycle still uses the current client serial, monitoring timer, timeout and lifecycle, and validates the complete response. The first cycle binds the Poller to that exact client and its profile/frame/compatibility; use another Poller for another client or configuration. Explicit close/reconnect of the same unchanged client does not invalidate the prepared plan. The bound client must outlive the Poller; destroying it invalidates the binding even if another client is constructed at the same memory address.

#include <Arduino.h>
#include <WiFi.h>

#include <string>
#include <vector>

#include <slmp_arduino_transport.h>
#include <slmp_high_level.h>
#include <slmp_minimal.h>

constexpr char kWifiSsid[] = "YOUR_WIFI_SSID";
constexpr char kWifiPassword[] = "YOUR_WIFI_PASSWORD";
constexpr char kPlcHost[] = "192.168.250.100";
constexpr uint16_t kTcpPort = 1025;
constexpr auto kProfile = slmp::highlevel::PlcProfile::IqR;

WiFiClient tcp;
slmp::ArduinoClientTransport transport(
    tcp,
    slmp::configureEsp32WifiClientKeepAlive,
    slmp::writeEsp32WifiClientNonBlocking);
uint8_t txBuffer[192] = {};
uint8_t rxBuffer[192] = {};
slmp::SlmpClient plc(transport, kProfile, slmp::TargetAddress{0x00, 0xFF, slmp::module_io::OwnStation, 0x00}, txBuffer, sizeof(txBuffer), rxBuffer, sizeof(rxBuffer));
slmp::highlevel::Poller poller;
slmp::highlevel::Snapshot snapshot;

void setup() {
    Serial.begin(115200);
    WiFi.begin(kWifiSsid, kWifiPassword);
    while (WiFi.status() != WL_CONNECTED) {
        delay(250);
    }
    plc.connect(kPlcHost, kTcpPort);
    poller.compile({"D100:U", "D101:S", "D200:F", "M1000:BIT"}, kProfile);
}

void loop() {
    const slmp::Error err = poller.readOnce(plc, snapshot);
    Serial.printf("poller: %s values=%u\n", slmp::errorString(err), static_cast<unsigned>(snapshot.size()));
    delay(1000);
}

Device range catalog

slmp::highlevel::readDeviceRangeCatalogForPlcProfile reads live device range bounds while asserting one explicit profile. The requested profile must exactly equal SlmpClient::plcProfile(), including unit-specific identities; sharing a base family is not enough. A mismatch returns InvalidArgument before any transport write. The API does not auto-discover the PLC profile. The catalog is for diagnostics and application-layer validation. Normal read/write helpers do not use it to reject addresses by configured upper bound before sending a request. The source rules for this catalog are maintained in the shared SLMP device ranges reference.

For profiles requiring live Z/ZR range probes, this function is an explicit read-only aggregate. It validates and snapshots the complete profile, rule, and bounded probe decision plan before its first request, then owns one synchronous client turn. The catalog keeps the declared device-row order but is non-atomic: PLC state can change between probe requests. Expected PLC NG responses delimit a readable range. Timeout, transport, protocol, lifecycle, or local-validation failure stops immediately and leaves the caller's catalog unchanged; partial entries are never published.

#include <Arduino.h>
#include <WiFi.h>

#include <slmp_arduino_transport.h>
#include <slmp_high_level.h>
#include <slmp_minimal.h>

constexpr char kWifiSsid[] = "YOUR_WIFI_SSID";
constexpr char kWifiPassword[] = "YOUR_WIFI_PASSWORD";
constexpr char kPlcHost[] = "192.168.250.100";
constexpr uint16_t kTcpPort = 1025;
constexpr auto kProfile = slmp::highlevel::PlcProfile::IqR;

WiFiClient tcp;
slmp::ArduinoClientTransport transport(
    tcp,
    slmp::configureEsp32WifiClientKeepAlive,
    slmp::writeEsp32WifiClientNonBlocking);
uint8_t txBuffer[192] = {};
uint8_t rxBuffer[192] = {};
slmp::SlmpClient plc(transport, kProfile, slmp::TargetAddress{0x00, 0xFF, slmp::module_io::OwnStation, 0x00}, txBuffer, sizeof(txBuffer), rxBuffer, sizeof(rxBuffer));
bool printedCatalog = false;

void setup() {
    Serial.begin(115200);
    WiFi.begin(kWifiSsid, kWifiPassword);
    while (WiFi.status() != WL_CONNECTED) {
        delay(250);
    }
    plc.connect(kPlcHost, kTcpPort);
}

void loop() {
    if (!printedCatalog) {
        slmp::highlevel::DeviceRangeCatalog catalog;
        const slmp::Error err = slmp::highlevel::readDeviceRangeCatalogForPlcProfile(plc, kProfile, catalog);
        if (err == slmp::Error::Ok && !catalog.entries.empty()) {
            const slmp::highlevel::DeviceRangeEntry& entry = catalog.entries.front();
            Serial.printf(
                "%s supported=%u range=%s\n",
                entry.device.c_str(),
                entry.supported ? 1U : 0U,
                entry.address_range.c_str());
        } else {
            Serial.printf("catalog failed: %s\n", slmp::errorString(err));
        }
        printedCatalog = true;
    }
    delay(1000);
}

Long device families

LTN, LSTN, LCN, and LZ are 32-bit families in the high-level API. Use slmp::highlevel::ValueType::U32 or slmp::highlevel::ValueType::S32.

LTN and LSTN use route-specific Direct helpers and are not valid inputs to readNamed, compileReadPlan, or Poller. LCN and LZ use canonical Random DWord access and remain valid in a named plan. A named plan must be representable as exactly one canonical Random Read request.

compileReadPlan applies the selected profile's canonical/default compatibility-mode limit. readNamed also revalidates the connected client's effective compatibility mode before transport; a manual compatibility override can therefore reject a plan that compiled for the profile default.

Family Unsigned form Signed form Caution
LTN LTN0:D LTN0:L Plain 16-bit access yields wrong data.
LSTN LSTN0:D LSTN0:L Plain 16-bit access yields wrong data.
LCN LCN0:D LCN0:L Plain 16-bit access yields wrong data.
LZ LZ0:D LZ0:L Plain 16-bit access yields wrong data.

Address reference table

Form Meaning Example
:U Unsigned 16-bit word. D100:U
:S Signed 16-bit word. D100:S
:D Unsigned 32-bit value from two words. D200:D
:L Signed 32-bit value from two words. D200:L
:F IEEE-754 float32 from two words. D300:F
:BIT Direct bit device value. M1000:BIT
.n One bit inside a word device, where n is hexadecimal 0 through F. D50.3

Named addresses used with readTyped(address), readNamed, writeNamed, and Poller must include the intended type, for example D100:U or M1000:BIT.

The module-buffer route names only word-addressable G and HG. Use readWordsModuleBuf and writeWordsModuleBuf; there is no module-buffer bit API. Qualified G/HG entries are likewise invalid in Extended Random bit writes.

Label data lengths

Array label unit_specification is 0 for a logical bit count and 1 for a logical byte count. Both forms occupy whole two-byte wire units: bit counts use ceil(array_data_length / 16) * 2 bytes and byte counts use ceil(array_data_length / 2) * 2 bytes. The logical length must be positive, and writeArrayLabels requires the exact padded data_bytes. Random label read and write lengths must also be positive and even. Read responses must match the requested count and, for array labels, each requested unit and logical length; malformed or trailing data returns Error::ProtocolError.

Request payload limits

One SLMP request can carry at most 65,529 command-payload bytes over a stream transport. IPv4 UDP must also fit one complete datagram, so the Arduino UDP command-payload maximum is 65,492 bytes for 3E and 65,488 bytes for 4E. Array and random label requests use even-sized payloads and therefore have a largest protocol-representable payload of 65,528 bytes before the lower UDP limit is applied.

Oversized requests return Error::InvalidArgument before send, request-frame publication, or 4E serial allocation and are never truncated or split automatically. Every custom transport must implement ITransport::maximumRequestFrameSize(): datagram transports return their complete-frame ceiling, while stream transports return static_cast<size_t>(-1) because the SLMP field limit still applies.

All low-level request methods and named operations are single-request APIs and never aggregate or split work. An application that intentionally issues multiple requests owns their order, coherence, errors, and any OutcomeUnknown result. writeBitInWord explicitly performs a read followed by a write for a direct word. writeBitInWordModuleBuf and writeBitInWordLinkDirect provide the same contract for immutable U-qualified module-buffer and J-qualified link-direct word routes. Address-form writeTyped uses that same two-request behavior when its address contains .bit; its other address forms remain single-request. Every bit-in-word entry prevalidates both requests, owns one local client turn, and uses one absolute deadline after admission. A successful read is always followed by its write, even when the bit is unchanged. The pair is non-atomic with respect to PLC logic or another controller, never retries automatically, and a possibly transmitted unconfirmed write returns OutcomeUnknown.

Traffic statistics

Call client.trafficStats() for a TrafficStats client-lifetime snapshot containing request_count, tx_bytes, and rx_bytes. Complete sends and complete received frames are counted; close and reconnect do not reset the snapshot.