Usage guide
The library exposes three practical entry paths. Pick one based on how much transport control your application needs.
Design summary
The public API is designed for host tools and MCU firmware:
- no exceptions
- no RTTI
- no dynamic allocation in the library
- caller-owned buffers via
mcprotocol::serial::Span - transport-agnostic client state machine
Span<T> is the library's C++17 non-owning contiguous view. Construct it from pointer/count,
pointer-pair, a C-array, or a matching std::array lvalue. Other containers use the explicit
Span<T>(container.data(), container.size()) form; rvalue arrays are rejected so the view cannot
immediately dangle. A mutable Span<T> converts to Span<const T>, never the reverse. try_at,
try_first, and try_subspan report invalid indexes or ranges without producing an invalid view;
operator[] requires index < size().
Raw octets use mcprotocol::serial::Byte, not std::byte. Byte has no implicit integer
conversion or arithmetic; use mcprotocol::serial::byte_to_integer<Integer>(value) when a numeric
representation is required.
Entry paths
| Entry path | Header | Use it when |
|---|---|---|
| High-level helpers | mcprotocol/serial/high_level.hpp |
You want protocol presets and string-address request builders. |
| Host sync facade | mcprotocol/serial/host_sync.hpp |
You are writing a blocking Linux or Windows bring-up tool. |
| Low-level async client | mcprotocol/serial/client.hpp |
You are integrating your own UART, DMA, interrupt, or scheduler layer. |
Entry path 1: high-level helpers
make_c4_ascii_format4_protocol(PlcProfile::..., SumCheckMode::..., RouteConfig {...}) creates a
ProtocolConfig preset with explicit checksum and route policies. Request builders such as
make_batch_read_words_request("D100", count, request) convert plain device strings into typed
request structs.
ProtocolConfig has no public default constructor. Use exactly one tagged construction path:
ProtocolConfig::c4_binary(profile, sum_check_mode, route)fixes C4 Binary/Format5 and exposes no ASCII-format input.ProtocolConfig::ascii(AsciiFrameKind::C4|C3|C2|C1, format, profile, sum_check_mode, route)requires an ASCII format.
The C-frame paths require SumCheckMode::Enabled or SumCheckMode::Disabled; the CLI likewise
requires --sum-check. The library never switches frame, code mode, format, profile, sum-check
policy, or route after an error or timeout.
2C supports only the documented compact command pairs 0401/0001 (1), 0401/0000 (2),
1401/0001 (3), 1401/0000 (4), 0403/0000 (5), 1402/0001 (6),
1402/0000 (7), 0801/0000 (8), and 0802/0000 (9), plus the full loopback pair
0619/0000. Any other command/subcommand selected through a public API returns
UnsupportedConfiguration before serial output; 2C does not fall back to a generic full header.
Those public command APIs remain available for their supported 3C/4C use.
Route selection
Use RouteConfig {HostStationRoute {}} for the connected host-station route. This type has no
station, network, PC, destination-module, or self-station inputs; the protocol-defined connected
station header is fixed internally. For multidrop, select the frame-specific type:
C1MultidropRoute(station) or a 2C/3C/4C topology-specific route.
For a normal or 1:n connection, use C2StandardMultidropRoute(station),
C3StandardMultidropRoute(station, network, pc_target), or
C4StandardMultidropRoute(station, network, pc_target, destination_module). For an m:n
connection, use the corresponding C2MnMultidropRoute, C3MnMultidropRoute, or
C4MnMultidropRoute and supply SelfStationNo::number(0U..0x1FU) as the final mandatory
argument. Station zero, network zero, and m:n self-station zero remain valid only when explicitly
passed. A 3C/4C PC target is constructed with
C34PcTarget::number(0x01U..0x78U) or one of control_system(), standby_system(),
special_fe(), and connected_station(). The special wire values cannot be passed through
number(), which prevents an ordinary number from silently acquiring special meaning. Raw numeric
CLI values 0x7D, 0x7E, 0xFE, and 0xFF are accepted only at that external boundary and
normalized to the corresponding canonical selector before validation.
The 4C destination module is also mandatory. Use C4DestinationModule::own_station(),
multiple_cpu(1U..4U), one of the four redundant_*_cpu() selectors, or
explicit_target(io_number, station_number) for a configuration-dependent target supported by the
selected hardware. The historical RemoteHead constants share wire values with Multiple CPU
constants; this library does not reinterpret one meaning as the other. Use an explicit target when
the module configuration—not the generic selector name—is the source of truth.
The self-station number identifies the request source on an m:n connection. Standard routes expose no self-station input and encode zero internally. The library validates the field width but cannot infer the C24 station assignment or the total station-count constraints of a particular wiring and parameter configuration. Assign those values from the actual serial-network configuration and do not reuse a C24-side station number accidentally.
RouteConfig {} is invalid. The CLI likewise requires --route host or --route multidrop.
3C/4C additionally require --network and --pc-target. A 4C multidrop route additionally requires --module-target; use
--module-target own when the explicit own-station selector is correct. Every 2C/3C/4C multidrop
CLI command also requires --topology standard|mn. standard rejects --self-station; mn
requires --self-station 0..31, including an explicit zero when zero is assigned. A station, PC,
module, topology, or self-station value never selects or changes a route implicitly.
Every response route header field that exists in the selected frame is compared with the configured route. A complete 2C/3C/4C response from a different self-station—or a 3C/4C response from a different station, network, or PC target, or a 4C response from a different destination module—is discarded while the client continues waiting for the matching response. Malformed ASCII route hexadecimal is reported as a parse error. Timeout, NAK, malformed input, or mismatch never causes automatic route discovery or fallback.
Absolute transaction timeout and response inactivity
TimeoutConfig::response_timeout_ms is the one absolute transaction deadline. Its omitted value
is 3000 ms for every frame and code mode. Call notify_tx_started(now_ms) immediately before the
first UART write. That same deadline covers partial writes, physical TX drain, all receive chunks,
response correlation, and complete decode. No byte, chunk, ignored response, or phase transition
restarts it. An explicit value must be 1..2147483647 ms so 32-bit monotonic-clock comparisons remain
wrap-safe.
TimeoutConfig::inter_byte_timeout_ms is a second, shorter RX inactivity limit. It defaults to
250 ms and starts only when the decoder retains a possible response frame. Each new chunk that
advances that retained candidate restarts the inactivity limit, but noise with no retained
candidate does not start it. Set it immutably with with_inter_byte_timeout_ms(value) when a valid
slow link needs more than 250 ms between observable chunks. Explicit values must also be
1..2147483647 ms; zero does not disable the check. The absolute transaction deadline is never
restarted, so whichever deadline expires first wins. The CLI equivalent is
--inter-byte-timeout-ms.
This is library-observed RX inactivity. When one OS read or UART callback delivers multiple physical bytes together, the library cannot observe or time the physical gaps inside that chunk.
Every timeout sets requires_transport_reset(), including Format2. Abort, drain, and close/reopen
the exact UART generation, then call configure() before another request. HostSyncClient does
this retirement itself and must be opened again. The timed-out request is not retried.
For the low-level async client, a deadline reached while physical TX is still pending is latched
rather than completed immediately. poll() sets the reset requirement but keeps the request busy,
keeps an active RS-485 direction hook asserted, and does not invoke the completion callback. Finish
or abort the UART/DMA operation and always call notify_tx_complete(). That notification releases
TX ownership once and publishes Timeout for a read, or OperationOutcomeUnknown with cause
Timeout for a state-changing request, even if the later physical notification reports another
transport status. If it is never reported, the client intentionally remains busy.
Remote RESET is the dedicated no-normal-response operation. Its completion means that the request bytes were transmitted; it does not wait three seconds and does not confirm that the PLC reset. Transport failure is still reported. Other commands, including global-signal control and transmission-sequence initialization, do not convert a response timeout into success.
Format2 request identity
Do not configure a fixed Format2 block number. MelsecSerialClient assigns a new value for each
wire request, advances through 00 to FF, wraps to 00 only after the prior request has finished,
and accepts only the matching response. A late response from a timed-out or cancelled request is
discarded instead of being returned as the next request's result.
FrameCodecContext::format2(number) is available only for raw frame construction, negative tests,
and protocol investigation where the caller intentionally owns one wire identity. Passing a
Format2 context to another format, or using a Format2 raw codec without one, is an error. The CLI
does not expose a normal --block-no connection option.
Public request and item types require their semantic inputs at construction. A missing device,
address, count/data span, value, target, state, channel, or requested mode change is never replaced
with D0, address zero, zero/OFF, or another valid operation. Explicit D0, address zero, value zero,
and Boolean false remain valid when passed by the caller. Individual bit inputs use native C++
bool only; packed block words remain std::uint16_t. Empty request containers are rejected before
any transmit frame is made. Receive/output storage types remain
default constructible.
#include <cstdint>
#include <cstdio>
#include "mcprotocol_serial.hpp"
int main() {
using mcprotocol::serial::BatchReadWordsRequest;
using mcprotocol::serial::DeviceAddress;
using mcprotocol::serial::DeviceCode;
using mcprotocol::serial::PlcProfile;
using mcprotocol::serial::ProtocolConfig;
using mcprotocol::serial::highlevel::make_c4_ascii_format4_protocol;
ProtocolConfig protocol = make_c4_ascii_format4_protocol(
PlcProfile::MelsecQ,
mcprotocol::serial::SumCheckMode::Disabled,
mcprotocol::serial::RouteConfig {mcprotocol::serial::HostStationRoute {}});
const BatchReadWordsRequest request(DeviceAddress {DeviceCode::D, 100U}, 2U);
std::printf("head=%u points=%u\n", request.head_device.number, request.points);
return 0;
}
Explicit bit-in-word updates
Use HostSyncClient::write_bit_in_word() for an ordinary device such as D100.
The synchronous facade also exposes route-specific forms for block-addressed
extended file registers, direct extended file registers, Jn\\... link-direct
devices, and qualified Un\\Gn / Un\\HGn word devices. The non-blocking
highlevel::BitInWordWriteOperation exposes matching begin(),
begin_extended_file_register(), begin_direct_extended_file_register(),
begin_link_direct(), and begin_qualified_buffer() forms.
Every form validates both requests and the immutable route before transmission,
uses one absolute deadline, and always sends one read followed by one write even
when the selected bit already has the requested state. This is not PLC-atomic:
PLC logic or another connection can change the word between requests. If
cancellation or failure occurs after the write may have started, the result is
StatusCode::OperationOutcomeUnknown; reset/reopen the transport and reconcile
PLC state before retrying. The operation supports only complete 16-bit word
routes; bit devices, standalone G/HG, long-state helpers, random access,
monitoring, and module/host byte-buffer APIs are not bit-in-word routes.
Entry path 2: synchronous host facade
HostSyncClient opens a host serial port, configures the protocol client, transmits one request, waits for completion, and returns a Status.
Use read_words_single_request, write_words_single_request,
read_bits_single_request, and write_bits_single_request for contiguous host
access that must be one PLC request. The shorter synchronous names remain
deprecated compatibility delegates; command-native async_batch_* APIs are
unchanged.
MC Serial 1E removal
MC Serial 1E has no public configuration, route, codec, client, CLI, or build-feature entry. Existing 1E callers must select the supported 1C, 2C, 3C, or 4C frame that is actually configured on the target serial module; there is no compatibility alias or automatic fallback. This removal does not add or redirect callers to Ethernet 1E. Ethernet integrations use their separately supported 3E/4E contract.
API name migration
New code should include mcprotocol/serial/host_serial.hpp for HostSerialConfig and
HostSerialPort, and mcprotocol/serial/host_sync.hpp for HostSyncClient. The host-enabled
mcprotocol_serial.hpp umbrella includes both. The old names remain deprecated delegates or type
aliases to the same implementation for one compatibility release; request bytes, results,
timeouts, and state transitions are unchanged. The old mcprotocol/serial/posix_serial.hpp header
is also a one-release compatibility include.
| Deprecated name | Canonical name |
|---|---|
PosixSerialConfig |
HostSerialConfig |
PosixSerialPort |
HostSerialPort |
PosixSyncClient |
HostSyncClient |
async_extended_batch_read_words |
async_qualified_buffer_batch_read_words |
async_extended_batch_write_words |
async_qualified_buffer_batch_write_words |
read_native_qualified_words |
read_qualified_buffer_words |
write_native_qualified_words |
write_qualified_buffer_words |
write_native_qualified_bit_in_word |
write_qualified_buffer_bit_in_word |
direct_read_extended_file_register_words |
read_direct_extended_file_register_words |
direct_write_extended_file_register_words |
write_direct_extended_file_register_words |
direct_write_extended_file_register_bit_in_word |
write_direct_extended_file_register_bit_in_word |
read_long_state_bits (both overloads) |
read_long_timer_counter_state_bits |
async_read_user_frame |
async_read_user_frame_registration |
async_write_user_frame |
async_write_user_frame_registration |
async_delete_user_frame |
async_delete_user_frame_registration |
read_user_frame |
read_user_frame_registration |
write_user_frame |
write_user_frame_registration |
delete_user_frame |
delete_user_frame_registration |
UserFrameReadRequest |
UserFrameRegistrationReadRequest |
UserFrameWriteRequest |
UserFrameRegistrationWriteRequest |
UserFrameDeleteRequest |
UserFrameRegistrationDeleteRequest |
async_register_monitor |
async_register_monitor_devices |
async_read_monitor |
async_run_monitor_cycle |
register_monitor |
register_monitor_devices |
read_monitor |
run_monitor_cycle |
random_read |
read_random |
random_read_word |
read_random_word |
random_read_dword |
read_random_dword |
random_write_words |
write_random_words |
random_write_dwords |
write_random_dwords |
random_write_word |
write_random_word |
random_write_dword |
write_random_dword |
random_write_bits |
write_random_bits |
random_write_bit |
write_random_bit |
random_write_extended_file_register_words |
write_random_extended_file_register_words |
begin_qualified_buffer, command codec names, the async direct-file-register names, single-item
monitor helpers, extended-file-register/link-direct monitor APIs, and CLI command names are not
renamed by this migration.
Read words
#include <array>
#include <cstdint>
#include <cstdio>
#include "mcprotocol_serial.hpp"
int main() {
using mcprotocol::serial::HardwareFlowControl;
using mcprotocol::serial::PlcProfile;
using mcprotocol::serial::HostSerialConfig;
using mcprotocol::serial::HostSyncClient;
using mcprotocol::serial::SerialParity;
using mcprotocol::serial::Status;
using mcprotocol::serial::highlevel::make_c4_ascii_format4_protocol;
const HostSerialConfig serial(
"/dev/ttyUSB0",
19200,
8,
1,
SerialParity::Even,
HardwareFlowControl::None);
auto protocol = make_c4_ascii_format4_protocol(
PlcProfile::MelsecQ,
mcprotocol::serial::SumCheckMode::Disabled,
mcprotocol::serial::RouteConfig {mcprotocol::serial::HostStationRoute {}});
HostSyncClient plc;
Status status = plc.open(serial, protocol);
if (!status.ok()) {
return 1;
}
std::array<std::uint16_t, 2> words {};
status = plc.read_words_single_request("D100", words);
if (!status.ok()) {
return 1;
}
std::printf("D100=0x%04X D101=0x%04X\n", words[0], words[1]);
return 0;
}
Write words
Run this example only on a controlled test PLC and a device range reserved for
the test. It saves the original words and restores them only after the test
write is confirmed. OperationOutcomeUnknown means the new values may already
be present and the host facade has retired the serial connection; do not retry
or attempt an immediate restore. Reopen, inspect, and reconcile the devices only
under an explicit application safety policy.
#include <array>
#include <cstdint>
#include <cstdio>
#include "mcprotocol_serial.hpp"
int main() {
using mcprotocol::serial::HardwareFlowControl;
using mcprotocol::serial::PlcProfile;
using mcprotocol::serial::HostSerialConfig;
using mcprotocol::serial::HostSyncClient;
using mcprotocol::serial::SerialParity;
using mcprotocol::serial::highlevel::make_c4_ascii_format4_protocol;
const HostSerialConfig serial(
"/dev/ttyUSB0",
19200,
8,
1,
SerialParity::Even,
HardwareFlowControl::None);
HostSyncClient plc;
auto protocol = make_c4_ascii_format4_protocol(
PlcProfile::MelsecQ,
mcprotocol::serial::SumCheckMode::Disabled,
mcprotocol::serial::RouteConfig {mcprotocol::serial::HostStationRoute {}});
if (!plc.open(serial, protocol).ok()) {
return 1;
}
std::array<std::uint16_t, 2> original {};
if (!plc.read_words_single_request("D100", original).ok()) {
return 1;
}
const std::array<std::uint16_t, 2> words {0x1234, 0x5678};
const auto write_status = plc.write_words_single_request("D100", words);
if (write_status.code == mcprotocol::serial::StatusCode::OperationOutcomeUnknown) {
std::fprintf(
stderr,
"write outcome unknown; reopen and inspect D100-D101 before reconciliation\n");
return 2;
}
if (!write_status.ok()) {
// A confirmed failure is reported before any restoration request is attempted.
return 1;
}
const auto restore_status = plc.write_words_single_request("D100", original);
if (restore_status.code == mcprotocol::serial::StatusCode::OperationOutcomeUnknown) {
std::fprintf(
stderr,
"restore outcome unknown; reopen and inspect D100-D101 manually\n");
return 2;
}
if (!restore_status.ok()) {
std::fputs("restore failed; inspect and reconcile D100-D101 manually\n", stderr);
return 1;
}
return 0;
}
Random read and random write
Run the random-write portion only on a controlled test PLC and a device reserved for the test. Save the original value first. Restore only after a confirmed write; an unknown write or restoration outcome requires reconnecting, inspecting the device, and reconciling it manually under an explicit safety policy.
#include <array>
#include <cstdint>
#include "mcprotocol_serial.hpp"
int main() {
using mcprotocol::serial::HardwareFlowControl;
using mcprotocol::serial::PlcProfile;
using mcprotocol::serial::HostSerialConfig;
using mcprotocol::serial::HostSyncClient;
using mcprotocol::serial::SerialParity;
using mcprotocol::serial::highlevel::RandomWriteDWordSpec;
using mcprotocol::serial::highlevel::RandomWriteWordSpec;
using mcprotocol::serial::highlevel::make_c4_ascii_format4_protocol;
const HostSerialConfig serial(
"/dev/ttyUSB0",
19200,
8,
1,
SerialParity::Even,
HardwareFlowControl::None);
HostSyncClient plc;
auto protocol = make_c4_ascii_format4_protocol(
PlcProfile::MelsecQ,
mcprotocol::serial::SumCheckMode::Disabled,
mcprotocol::serial::RouteConfig {mcprotocol::serial::HostStationRoute {}});
if (!plc.open(serial, protocol).ok()) {
return 1;
}
std::uint16_t d100 = 0;
if (!plc.read_random_word("D100", d100).ok()) {
return 1;
}
std::array<std::uint16_t, 1> original_d101 {};
if (!plc.read_words_single_request("D101", original_d101).ok()) {
return 1;
}
const std::array<RandomWriteWordSpec, 1> writes {{RandomWriteWordSpec("D101", d100)}};
const auto write_status = plc.write_random_words(writes);
if (write_status.code == mcprotocol::serial::StatusCode::OperationOutcomeUnknown) {
// The PLC may already have applied the value. Inspect the target; do not retry automatically.
return 2;
}
if (!write_status.ok()) {
// Report the confirmed failure before attempting any restoration request.
return 1;
}
const auto restore_status = plc.write_words_single_request("D101", original_d101);
if (restore_status.code == mcprotocol::serial::StatusCode::OperationOutcomeUnknown) {
// Reopen and inspect D101 manually; do not assume restoration succeeded.
return 2;
}
if (!restore_status.ok()) {
// Inspect and reconcile D101 manually before continuing.
return 1;
}
return 0;
}
Remote control and CPU model
Remote STOP/RUN changes CPU execution state. The example is disabled by default and is only for a controlled test PLC whose outputs and process are already in a safe condition. Before opting in, verify and record that the original CPU state is RUN. This library does not infer that state. If STOP or the compensating RUN is unconfirmed, the PLC may remain STOPped; inspect it and restore the recorded state manually after reconnecting instead of retrying automatically.
#include <cstdio>
#include "mcprotocol_serial.hpp"
int main() {
using mcprotocol::serial::CpuModelInfo;
using mcprotocol::serial::HardwareFlowControl;
using mcprotocol::serial::PlcProfile;
using mcprotocol::serial::HostSerialConfig;
using mcprotocol::serial::HostSyncClient;
using mcprotocol::serial::RemoteOperationMode;
using mcprotocol::serial::RemoteRunClearMode;
using mcprotocol::serial::SerialParity;
using mcprotocol::serial::highlevel::make_c4_ascii_format4_protocol;
const HostSerialConfig serial(
"/dev/ttyUSB0",
19200,
8,
1,
SerialParity::Even,
HardwareFlowControl::None);
HostSyncClient plc;
auto protocol = make_c4_ascii_format4_protocol(
PlcProfile::MelsecQ,
mcprotocol::serial::SumCheckMode::Disabled,
mcprotocol::serial::RouteConfig {mcprotocol::serial::HostStationRoute {}});
if (!plc.open(serial, protocol).ok()) {
return 1;
}
CpuModelInfo info {};
if (!plc.read_cpu_model(info).ok()) {
return 1;
}
std::printf("model=%s code=0x%04X\n", info.model_name.data(), info.model_code);
constexpr bool kControlledTestApproved = false;
constexpr bool kCpuWasRunningBeforeTest = false;
if (!kControlledTestApproved) {
std::puts("remote STOP/RUN skipped; controlled-test approval is required");
return 0;
}
if (!kCpuWasRunningBeforeTest) {
std::puts("remote STOP/RUN skipped; original CPU state was not RUN");
return 0;
}
const auto stop_status = plc.remote_stop();
if (stop_status.code == mcprotocol::serial::StatusCode::OperationOutcomeUnknown) {
std::fputs("STOP outcome unknown; inspect CPU state and do not retry\n", stderr);
return 2;
}
if (!stop_status.ok()) {
return 1;
}
const auto run_status = plc.remote_run(
RemoteOperationMode::DoNotExecuteForcibly,
RemoteRunClearMode::DoNotClear);
if (run_status.code == mcprotocol::serial::StatusCode::OperationOutcomeUnknown) {
std::fputs(
"RUN outcome unknown; the PLC may remain STOPped, so inspect it manually\n",
stderr);
return 2;
}
if (!run_status.ok()) {
std::fputs("RUN failed; the PLC remains STOPped and needs manual recovery\n", stderr);
return 1;
}
return 0;
}
Sparse access never infers width from the device name. Use read_random_word/
RandomWriteWordSpec for 16-bit data and read_random_dword/RandomWriteDWordSpec for 32-bit
data. Mixed low-level requests keep separate word_items/dword_items and separate typed output
spans. LZ, LTN, LSTN, and LCN require the DWord path. Link-direct sparse read, write, and
monitor APIs are Word-only.
Every random-write item/spec is constructed with both the target and value. There is no default
constructor and no omitted-value meaning. Explicit Word/DWord zero and Boolean false are valid;
missing, empty, or out-of-range values reject the complete request before transmission.
The CLI follows the same rule: use random-write-words D100=0, random-write-dwords D200=0, or
random-write-bits M100=0; a missing =VALUE is rejected before the serial device is opened.
After transmission begins, an unconfirmed random-write result is OperationOutcomeUnknown, because
the PLC may already have changed. The library clears the pending frame and never retries the write.
Additional synchronous single-request facades
HostSyncClient also exposes the existing async link-direct, loopback, multi-block, and
link-direct-monitor operations without changing their request or output types:
std::array<mcprotocol::serial::LinkDirectRandomReadWordItem, 2> sparse {{
{{1, {mcprotocol::serial::DeviceCode::W, 0x100}}},
{{2, {mcprotocol::serial::DeviceCode::X, 0x10}}},
}};
std::array<std::uint16_t, 2> sparse_words {};
auto status = plc.read_random_link_direct_words(sparse, sparse_words);
std::array<char, 8> echoed {};
status = plc.self_test_loopback(mcprotocol::serial::Span<const char>("0123", 4), echoed);
std::array<std::uint16_t, 8> block_words {};
std::array<mcprotocol::serial::BitValue, 32> block_bits {};
std::array<mcprotocol::serial::MultiBlockReadBlockResult, 2> block_results {};
status = plc.read_block(request, block_words, block_bits, block_results);
status = plc.register_link_direct_monitor_devices(registration);
if (status.ok()) {
status = plc.run_monitor_cycle(monitor_words, {});
}
The complete synchronous additions are read_random_link_direct_words,
write_random_link_direct_words, write_random_link_direct_bits, self_test_loopback,
read_block, write_block, read_link_direct_block, write_link_direct_block, and
register_link_direct_monitor_devices. Each method passes caller-owned spans and the existing
request type directly to the corresponding async operation, waits for that one request, and returns
the same status. There is no allocation, request splitting, fallback, or automatic retry in these
facades.
For multi-block reads, out_results follows request order. Word and bit blocks have independent
offset domains in their respective flat output buffers. A bit-block points value counts 16-bit
units, so one point expands to 16 BitValue entries. A parse failure can leave output and result
spans partially updated; the facade does not add an all-or-nothing guarantee. Link-direct result
metadata retains the inner DeviceAddress; each network number remains in the original request.
self_test_loopback writes the echo into out_echoed; a buffer exactly as long as the input is not
NUL-terminated, while a buffer with spare capacity receives the parser's trailing NUL. Link-direct
monitor registration remains separate from the common run_monitor_cycle: registration is
word-only (a bit-device item still occupies one std::uint16_t result slot), and a cycle never
registers implicitly. Any unconfirmed post-send write or registration result is
OperationOutcomeUnknown; inspect state and do not resend automatically.
This unknown-outcome rule applies to every state-changing command, including contiguous and block writes, buffer and file-register writes, remote control, password lock state, user-frame changes, global signal control, mode switching, and transmission-sequence initialization. A timeout, transport failure, cancellation, malformed response, or other result that cannot confirm the PLC state is not reported as a definite pre-send failure. Inspect the target state before deciding what to do next; the library does not resend automatically.
| Status | Meaning / retry rule |
|---|---|
Timeout |
The configured absolute deadline expired for a read or before a state change could be ambiguous. Reopen/reset before a later operation. |
Cancelled |
The caller cancelled; this is not a timeout. |
Closed |
A local lifecycle close interrupted/rejected the operation. |
NotConnected |
No configured/open session exists. |
Transport |
Non-timeout local I/O failure. |
Framing / Parse / SumCheckMismatch |
A response was malformed or invalid. |
PlcError |
The PLC returned a confirmed NG/end code; inspect plc_error_code. |
OperationOutcomeUnknown |
A state-changing request may have been sent. Do not retry automatically; inspect status.cause (Timeout, Cancelled, Closed, Transport, or protocol reason) and verify PLC state. |
OutOfMemory |
A host-only aggregate could not allocate its result stage before communication. No PLC request was sent; reduce the point count or free host memory. |
The CLI prints the machine classification before the message. For an unknown state-changing result,
it also prints the structured cause, for example OperationOutcomeUnknown: ... (cause=Timeout).
The synchronous facade and CLI both use the core completion callback as the final result after a
request is admitted; neither keeps a separate state-changing command list.
Remote RUN always requires two explicit decisions. RemoteOperationMode selects whether a RUN
conflict is handled forcibly. RemoteRunClearMode selects whether device state is retained,
cleared outside the latch range, or cleared completely. There is no overload that infers either
choice. If transmission starts but no trustworthy result is received, the status is
OperationOutcomeUnknown: the PLC may already be RUN, so check its state instead of resending the
command automatically.
Remote PAUSE also requires an explicit RemoteOperationMode. This mode controls conflict handling
when another external device owns the remote STOP/PAUSE operation; it is not an output-retention
setting. DoNotExecuteForcibly preserves that ownership conflict, while ExecuteForcibly
overrides it. The library never changes from non-forced to forced after an error. An unconfirmed
PAUSE returns OperationOutcomeUnknown, so inspect the PLC state and do not resend automatically.
Entry path 3: low-level async client
MelsecSerialClient owns the protocol state machine but not the UART. Your code configures the
client, starts an async request, calls notify_tx_started(now) immediately before its first UART
write, sends pending_tx_frame(), calls notify_tx_complete(now, status), feeds response bytes
with on_rx_bytes(), calls notify_rx_failure(status) if response reception fails, and calls
poll() for deadline handling. TX and receive failures are always explicit: pass ok_status() to
notify_tx_complete() only after the UART confirms physical transmission completion, and otherwise
pass the actual transport failure. Use cancel() only for caller-requested cancellation; do not
replace a receive-side Timeout or Transport status with cancellation.
One instance admits one wire transaction. A second operation returns Busy before request-state
mutation. The class retains caller-owned spans, so it has no internal pending queue. Calls from
different operating-system threads into the same instance are prohibited; schedule them in the
caller. Separate instances have independent state and may progress concurrently.
RS-485 direction hooks are optional. Leave both callbacks unset for RS-232 or hardware/driver-
controlled RS-485. When application-controlled direction is needed, install both on_tx_begin and
on_tx_end together; a one-sided hook is rejected. Hooks cannot be replaced while a request is
busy. If cancellation is requested during TX, the request remains busy until the UART reports
physical completion or abort with notify_tx_complete; only then is on_tx_end called exactly
once and the completion callback released. Cancellation before notify_tx_started() completes
immediately as Cancelled, invokes no TX hook, and cannot become OperationOutcomeUnknown because
no transport write has started.
The same physical-ownership rule applies when poll() observes the absolute deadline during TX.
The timeout is already logically fixed, so a later transport result cannot override its cause, but
the hook, callback, and busy state are retained until notify_tx_complete() confirms completion or
abort.
This is the path used in the PlatformIO examples.
#include <array>
#include <cstddef>
#include <cstdint>
#include <cstdio>
#include <cstring>
#include "mcprotocol_serial.hpp"
#include "mcprotocol/serial/span.hpp"
namespace {
struct Completion {
bool done = false;
mcprotocol::serial::Status status {};
};
void on_complete(void* user, mcprotocol::serial::Status status) {
auto* completion = static_cast<Completion*>(user);
completion->done = true;
completion->status = status;
}
} // namespace
int main() {
using mcprotocol::serial::BatchReadWordsRequest;
using mcprotocol::serial::DeviceAddress;
using mcprotocol::serial::DeviceCode;
using mcprotocol::serial::FrameCodec;
using mcprotocol::serial::MelsecSerialClient;
using mcprotocol::serial::PlcProfile;
using mcprotocol::serial::Status;
using mcprotocol::serial::highlevel::make_c4_ascii_format4_protocol;
MelsecSerialClient client;
const auto protocol = make_c4_ascii_format4_protocol(
PlcProfile::MelsecQ,
mcprotocol::serial::SumCheckMode::Disabled,
mcprotocol::serial::RouteConfig {mcprotocol::serial::HostStationRoute {}});
Status status = client.configure(protocol);
if (!status.ok()) {
return 1;
}
std::array<std::uint16_t, 2> words {};
Completion completion {};
status = client.async_batch_read_words(
0,
BatchReadWordsRequest(
DeviceAddress {DeviceCode::D, 100U},
static_cast<std::uint16_t>(words.size())),
mcprotocol::serial::Span<std::uint16_t>(words.data(), words.size()),
on_complete,
&completion);
if (!status.ok()) {
return 1;
}
const mcprotocol::serial::Span<const mcprotocol::serial::Byte> frame = client.pending_tx_frame();
status = client.notify_tx_started(0);
if (!status.ok()) {
return 1;
}
// Send `frame` through your UART here.
(void)frame;
status = client.notify_tx_complete(1, mcprotocol::serial::ok_status());
if (!status.ok()) {
return 1;
}
const std::array<std::uint8_t, 8> response_data {'1', '2', '3', '4', '5', '6', '7', '8'};
std::array<std::uint8_t, mcprotocol::serial::kMaxResponseFrameBytes> response_frame {};
std::size_t response_frame_size = 0;
status = FrameCodec::encode_success_response(
protocol,
mcprotocol::serial::Span<const std::uint8_t>(response_data.data(), response_data.size()),
response_frame,
response_frame_size);
if (!status.ok()) {
return 1;
}
std::array<mcprotocol::serial::Byte, mcprotocol::serial::kMaxResponseFrameBytes> rx_frame {};
std::memcpy(rx_frame.data(), response_frame.data(), response_frame_size);
client.on_rx_bytes(2, mcprotocol::serial::Span<const mcprotocol::serial::Byte>(rx_frame.data(), response_frame_size));
client.poll(2);
if (!completion.done || !completion.status.ok()) {
return 1;
}
std::printf("D100=0x%04X D101=0x%04X\n", words[0], words[1]);
return 0;
}
See examples/mcu_async_batch_read.cpp for a complete simulated async example.
Address format
| Address form | Example | Status |
|---|---|---|
| Plain decimal word device | D100 |
Supported. |
| Plain decimal bit device | M100 |
Supported. |
| Plain hexadecimal bit device | X10 |
Supported. |
| Plain hexadecimal word device | W100 |
Supported. |
| Typed suffix | D100:D, D100:F |
Not supported by the current parser. |
| Bit-in-word suffix | D100.0, D100.F |
Not supported by the current parser. |
| Link-direct string | J1\W100 |
Parsed by link-direct helpers, not by parse_device_address(). |
Special helper notes
- Use
read_long_timer_counter_state_bits()forLTS/LTC/LSTS/LSTC/LCS/LCCstate reads. Timer and retentive timer state devices use the long-current status block internally;LCS/LCCuse direct bit reads internally. - Use
read_link_direct_*()/write_link_direct_*()forJn\X/Y/B/SBbit devices andJn\W/SWword devices. C4 Binary / Format5 and C4 ASCII / Format4 are both confirmed for the validated Q and iQ-R targets when the serial module is configured for the matching format. - Use
read_qualified_buffer_words()/write_qualified_buffer_words()for CPU-bufferU3E0throughU3E3G/HGaccess and for profiles whose supportedUn\Groute is native device access. - The
0601/1601qualified helper route accepts only non-CPUUn\Gtargets. It rejectsU3E0throughU3E3, everyHGtarget, and profiles such asmelsec:iq-rthat require native-qualified access. - Set
MCPROTOCOL_SERIAL_TRACE=1when using the synchronous host client to log MC TX/RX frame bytes to stderr.
Build-time tuning
Single-request capacity and aggregation
Except for the aggregate described below, every public operation represents exactly one PLC
request. It never splits an oversized call, retries a smaller count, or grows beyond the selected
fixed capacities. Admission uses the minimum protocol/profile/wire/request/response/decoder/output
limit; binary calculations assume every escapable byte expands through DLE stuffing. A request that
cannot fit returns InvalidArgument before TX, while a caller output span that is independently too
small returns BufferTooSmall.
HostSyncClient::read_long_timer_counter_state_bits() is explicitly aggregate when an
LTS/LTC/LSTS/LSTC call requests more than one point. It validates the complete address,
profile, request, and response plan before the first send; issues one four-word status-block request
per point in address order; stops at the first failure; and changes caller output only after every
request succeeds. The result is non-atomic because internal requests can observe different PLC scan
times. Use a one-point read or a PLC-side snapshot/handshake when the values must share one coherence
point. LCS/LCC remain one direct bit request.
Callers that need any other group of independent reads or writes submit explicit operations. Writes are never automatically split, because partial completion and outcome-unknown handling must remain explicit.
For small firmware builds, use the PlatformIO environments or define the same macros in your own build.
| Tuning area | Macros |
|---|---|
| Buffer capacity | MCPROTOCOL_SERIAL_MAX_REQUEST_FRAME_BYTES, MCPROTOCOL_SERIAL_MAX_RESPONSE_FRAME_BYTES, MCPROTOCOL_SERIAL_MAX_REQUEST_DATA_BYTES, MCPROTOCOL_SERIAL_MAX_RANDOM_ACCESS_ITEMS, MCPROTOCOL_SERIAL_MAX_MULTI_BLOCK_COUNT, MCPROTOCOL_SERIAL_MAX_MONITOR_ITEMS, MCPROTOCOL_SERIAL_MAX_LOOPBACK_BYTES |
| Command families | MCPROTOCOL_SERIAL_ENABLE_RANDOM_COMMANDS, MCPROTOCOL_SERIAL_ENABLE_MULTI_BLOCK_COMMANDS, MCPROTOCOL_SERIAL_ENABLE_MONITOR_COMMANDS, MCPROTOCOL_SERIAL_ENABLE_HOST_BUFFER_COMMANDS, MCPROTOCOL_SERIAL_ENABLE_MODULE_BUFFER_COMMANDS, MCPROTOCOL_SERIAL_ENABLE_CPU_MODEL_COMMANDS, MCPROTOCOL_SERIAL_ENABLE_LOOPBACK_COMMANDS |
| Codec families | MCPROTOCOL_SERIAL_ENABLE_ASCII_MODE, MCPROTOCOL_SERIAL_ENABLE_BINARY_MODE, MCPROTOCOL_SERIAL_ENABLE_FRAME_C4, MCPROTOCOL_SERIAL_ENABLE_FRAME_C3, MCPROTOCOL_SERIAL_ENABLE_FRAME_C2, MCPROTOCOL_SERIAL_ENABLE_FRAME_C1 |
CMake exposes the same footprint presets through MCPROTOCOL_FEATURE_PROFILE:
| Profile | Behavior |
|---|---|
full |
Complete host-oriented build, including host sync and CLI. |
reduced |
Core-only build with smaller buffers, random/multi-block/monitor/host-buffer/module-buffer disabled, and codec limited to 4C + ASCII. |
ultra |
Reduced profile plus no CPU-model or loopback helpers. |
Non-full CMake profiles are core-only. Host sync, CLI, and tests are disabled automatically unless you override the build.
CMake preserves exception and RTTI support by default. A size-constrained build can set
MCPROTOCOL_DISABLE_EXCEPTIONS_RTTI=ON; those flags apply only while compiling the library target
and are not propagated to applications that link it.
Serial config reference
HostSerialConfig has no default constructor. All six connection fields are required and are
validated before the OS serial handle is opened. Values must match the PLC serial module and host
adapter; the library does not infer or retry a different setting. A successful open replaces the
port's inherited line behavior: POSIX input/output/local modes are raw, and Win32 preserves only
documented driver-reserved/provider DCB state. Software flow control and DTR/DSR flow are disabled,
and RTS is either disabled (None) or owned by the OS RTS/CTS handshake (RtsCts).
| Field | Type | Example | Notes |
|---|---|---|---|
device_path |
std::string_view |
/dev/ttyUSB0, COM3 |
Host serial device path. |
baud_rate |
std::uint32_t |
19200 |
Must match the PLC serial module. |
data_bits |
std::uint32_t |
8 |
Binary requires 8. ASCII accepts an explicit 7 or 8. |
stop_bits |
std::uint32_t |
1 |
Explicitly select 1 or 2 to match the module. |
parity |
SerialParity |
SerialParity::Even |
Explicitly select None, Even, or Odd. |
hardware_flow_control |
HardwareFlowControl |
HardwareFlowControl::None |
Explicitly select None or RtsCts; this is separate from RS-485 direction control. |