Skip to content

Usage guide

Connection contract

HostLinkConnectionOptions::new(host, port, transport, plc_profile) requires all endpoint and profile choices. Timeout defaults to 3 seconds and must be greater than zero when changed.

TCP and UDP are IPv4-only. Hostnames are resolved, but only IPv4 results are used; IPv6 literals are rejected before a socket is created. IPv4 literals must be written without URI-style brackets (127.0.0.1, not [127.0.0.1]).

use plc_comm_kv_hostlink::{
    HostLinkClient, HostLinkConnectionOptions, HostLinkTransportMode,
};

let options = HostLinkConnectionOptions::new(
    "192.168.250.100",
    8501,
    HostLinkTransportMode::Tcp,
    "keyence:kv-8000",
)?;
let client = HostLinkClient::new(options);
client.open().await?;

Normal command frames always end in CR (0x0D). There is no public LF append option or receive-buffer-size option. TCP and UDP responses have an internal absolute body cap of 65,536 bytes. Raw request bodies have a 65,506-byte cap, so the terminating CR makes the complete TCP/UDP request frame at most 65,507 bytes. Empty raw command bodies and one byte over the request limit are rejected before client state or traffic counters change.

One non-pipelined TCP request owns exactly one non-empty response. Additional CR/LF separators are ignored, but a second non-empty response received before another request owns it is a protocol error and retires the connection. For TCP as well as UDP, Host Link has no request identifier. Serialization and the pre-send unowned-data check therefore cannot distinguish a nonconforming late response that arrives between the final check and the next send. The client keeps a healthy TCP connection persistent because opening one connection per request would add a TCP handshake to every normal command without adding a protocol request identifier. Use a conforming endpoint; every anomaly that can be observed retires the connection and requires an explicit reopen.

For UDP, open creates one connected IPv4 UDP socket for the logical session. Complete valid exchanges reuse that socket and its local endpoint. A timeout, cancellation, transport/protocol failure, malformed response, extra response, or datagram already waiting before a send discards the socket; the next command creates a replacement from the already resolved remote endpoint without a new DNS lookup. An explicit close discards both the socket and logical session. Because Host Link has no request identifier, a duplicate datagram that arrives after the pre-send check but before the current response cannot be distinguished perfectly; use a conforming endpoint and separate clients when isolation is required.

open is idempotent while the same transport remains healthy. TCP transport failure, timeout, EOF, response overflow, or a dropped in-flight future retires the connection; call open before the next TCP command. UDP retires only the affected socket and creates a replacement on the next command. Dropping a future produces no library Result; a possibly transmitted write remains outcome-unknown, and no failed or abandoned command is retried.

Typed values and address grammar

High-level addresses use a colon for the value type:

Form Meaning
DM100:U unsigned 16-bit word
DM100:S signed 16-bit word
DM100:D unsigned 32-bit value
DM100:L signed 32-bit value
DM100:F 32-bit floating point value
R100:BIT direct bit device
DM100:COMMENT device comment
DM100.0 through DM100.F bit 0 through bit 15 in one word

Therefore, DM100.D means bit 13, while DM100:D means an unsigned Dword. Float32 (F) parsing, formatting, reads, and writes are defined only for the canonical ordinary .U families DM, EM, FM, ZF, W, TM, CM, VM, D, E, and F. Direct-bit, Z, and special-response families such as R, T, C, and AT reject :F before FIFO admission and frame construction; they are never reinterpreted as consecutive word or bit operations.

Every numeric semantic .H value is the canonical four-character uppercase form 0000 through FFFF. Short or lowercase PLC tokens are accepted only after 16-bit hexadecimal validation and are then padded, for example a becomes 000A. Raw response APIs and hexadecimal write-frame spelling are unchanged.

A low-level T/C single read returns three fields: structural status, current value, and preset value. Status is validated from the PLC token as exact 0 or 1; the selected .U, .S, .H, .D, or .L format applies only to current and preset. For example, a valid .H response is returned as ["0", "270F", "270F"], not ["0000", "270F", "270F"]. Code that previously compared the first low-level token with 0000 or 0001 must compare it with 0 or 1. High-level timer/counter result types are unchanged.

Low-level numeric APIs require a base device and a separate format:

let original = client
    .read_consecutive("DM200", 2, Some("D"))
    .await?
    .into_iter()
    .map(|value| value.parse::<u32>())
    .collect::<Result<Vec<_>, _>>()?;
client.write_consecutive("DM200", &[1_u32, 2_u32], Some("D")).await?;
let readback_result = client.read_consecutive("DM200", 2, Some("D")).await;
let restore_result = client.write_consecutive("DM200", &original, Some("D")).await;
restore_result?;
let values = readback_result?;

Use only a range reserved for controlled testing. Restoration is attempted after a confirmed write and before a readback error is propagated. If restoration fails, inspect and reconcile the range explicitly. If the write outcome is unknown, do not issue an automatic restore or retry.

Custom low-level values implement the fallible formatter contract:

use plc_comm_kv_hostlink::{HostLinkError, HostLinkPayloadValue};

struct Code(u16);

impl HostLinkPayloadValue for Code {
    fn format_for_suffix(&self, suffix: &str) -> Result<String, HostLinkError> {
        match suffix {
            ".U" => Ok(self.0.to_string()),
            _ => Err(HostLinkError::protocol(format!(
                "Code does not support suffix '{suffix}'"
            ))),
        }
    }
}

append_to_payload and all normal write helpers propagate this Result and send nothing on error. An empty successful token is also rejected without changing the output. Do not return an empty string or another fallback token.

Passing DM100.D to a low-level numeric API is rejected even when a matching format argument is also present. Direct bit devices use None because the device family and command already determine bit semantics; numeric devices do not use None as a default. Direct-bit writes accept Rust bool values only; numeric and textual Boolean aliases are rejected before transport.

Single-request block helpers

read_words, read_dwords, write_words_single_request, and write_dwords_single_request each send at most one Host Link command. Unsigned Dword helpers use native .D commands. Word requests accept at most 1,000 values and Dword requests at most 500 values, subject to stricter device-family limits.

The library has no general chunked helper. A low-level read or write is never divided automatically. If an application intentionally uses several requests, it must own the address progression, timing difference, retry policy, and partial-write handling.

Named read results and polling

let values = client
    .read_named(&["DM0:U", "DM1:S", "DM2:D", "DM4:F", "DM120.D"])
    .await?;

read_named is the one read-only aggregate allowed to plan multiple requests. All addresses are copied and validated before FIFO admission or the first send. Wire-compatible device types are grouped in their first-appearance order. Each group is sorted by address and contiguous ranges are merged up to the request limit, so alternating device types or descending input do not add avoidable PLC round trips. A new segment starts only at a declared value boundary, so a Dword or Float32 value is never split. Wire order is the optimized group/address order; returned keys and values retain declared input order.

Named keys must be semantically unique by device family, numeric address, dtype, bit index, and scalar count. Case and leading zeros do not make a second key distinct. Different dtype views of the same word, different bit indices, and overlapping multiword spans are valid. Result keys preserve the original input strings.

The complete named read (or one poll cycle) owns one FIFO wire turn and returns all requested values or an error, never a partial result. Multiple request frames are not a PLC-atomic observation: PLC scan timing can differ between segments. Applications that need one coherent PLC snapshot must use a single request or an explicit PLC-side snapshot/handshake design. Named reads and polls require at least one address, and poll intervals must be greater than zero; invalid input fails before FIFO admission or communication. poll reuses the validated minimum-request plan for each cycle. The FIFO turn is released after the cycle's final response is decoded and staged; result-object assembly and the completion-to-next-cycle interval do not block another wire operation.

The ordinary read_named and poll APIs reject :COMMENT entries during complete-plan validation and send no request. When an aggregate intentionally contains comments, use read_named_with_comment_encoding or poll_with_comment_encoding and pass one explicit HostLinkCommentEncoding. Those explicit variants require at least one :COMMENT entry; a non-comment-only list rejects the unused encoding before FIFO admission or communication.

Bit-in-word access

Bit-in-word notation uses hexadecimal indexes (DM120.0 through DM120.F). Use the explicit operation only when a client-side read-modify-write is the intended policy:

client.write_bit_in_word("DM120", 10, true).await?;
// Equivalent helper:
plc_comm_kv_hostlink::write_bit_in_word(&client, "DM120", 10, true).await?;

The value is a Rust bool, the index is 0..=15, and the target must be an ordinary 16-bit word device. The complete plan is rejected before FIFO admission if invalid. After activation, one absolute transaction deadline covers exactly one word read and one word write in the same client FIFO turn; queue wait is outside that deadline. The write is sent even when the bit is already in the requested state. There is no fallback, resend, success readback, or implicit named-write behavior.

The operation is not PLC-atomic. PLC logic or another connection can update the word between requests and that update can be lost. Use PLC-side logic, a handshake, or exclusive complete-word ownership for stronger guarantees. Dropping before the write starts sends no write. A drop after write transmission may have started has unknown PLC outcome and retires the transport; reopen and reconcile instead of retrying automatically. A returned complete PLC error is definitive and does not by itself retire a healthy connection.

Expansion-unit buffer memory uses its own route-specific operation:

client
    .write_bit_in_expansion_unit_buffer(1, 100, 3, true)
    .await?;
// Equivalent helper:
plc_comm_kv_hostlink::write_bit_in_expansion_unit_buffer(
    &client, 1, 100, 3, true,
)
.await?;

The unit/address and one .U word remain immutable across exactly one URD point and one UWR point. Ordinary-device and expansion-unit routes never fall back to one another. The shared-deadline, future-drop/outcome reconciliation, no-readback, and non-PLC-atomic rules are otherwise identical.

Word monitor registration

MWS entries beginning at a direct-bit device are packed word views, not individual Boolean entries. Use the explicit packed constructor so the API matches the PLC response contract:

use plc_comm_kv_hostlink::HostLinkMonitorWord;

client
    .register_monitor_words(&[
        HostLinkMonitorWord::numeric("DM120", "U"),
        HostLinkMonitorWord::packed_direct_bits_u16("R5000"),
    ])
    .await?;
let values = client.read_monitor_words().await?;

The packed constructor contributes the bare R5000 token to MWS; registered by itself it sends MWS R5000. Its MWR field must be exactly 1-5 ASCII decimal digits with a numeric value from 0 through 65535. Leading zeros are optional, so 0, 2, 13, 00000, 00002, 00013, and 65535 are valid and retain their original String spelling. Empty fields, signs, whitespace, nondecimal text, six or more digits, and overflow are rejected. It is not valid to treat this field as a Boolean. Use register_monitor_bits and read_monitor_bits when each registered entry must remain an exact 0/1/ON/OFF bit.

The former HostLinkMonitorWord::DirectBit/direct_bit API was removed without an alias. Replace it with packed_direct_bits_u16 when the intended operation is bare-wire packed MWS, or move the operation to the bit monitor APIs when the intended result is an individual bit.

Expansion-unit buffer access

Both URD and UWR require one of U, S, D, L, or H:

let original = client
    .read_expansion_unit_buffer(2, 200, 2, "S")
    .await?
    .into_iter()
    .map(|value| value.parse::<i16>())
    .collect::<Result<Vec<_>, _>>()?;
client
    .write_expansion_unit_buffer(2, 200, &[7_i16, 8_i16], "S")
    .await?;
let readback_result = client
    .read_expansion_unit_buffer(2, 200, 2, "S")
    .await;
let restore_result = client
    .write_expansion_unit_buffer(2, 200, &original, "S")
    .await;
restore_result?;
let values = readback_result?;

The format controls signedness, width, point limits, and buffer span. Missing or empty formats, invalid tokens, out-of-range values, and 32-bit end crossing are rejected rather than converted. Use only a configured unit and buffer range reserved for controlled testing. If the write outcome is unknown, inspect and reconcile the module state explicitly rather than restoring or retrying blindly. If restoration fails after a confirmed write, inspect the buffer range and reconcile its values manually before continuing.

Comments

use plc_comm_kv_hostlink::HostLinkCommentEncoding;

let utf8_comment = client
    .read_comments("DM20", HostLinkCommentEncoding::Utf8)
    .await?;
let cp932_comment = client
    .read_comments("DM21", HostLinkCommentEncoding::Cp932)
    .await?;
let exact_payload = client.read_comment_bytes("DM22").await?;

There is no automatic, default, or profile-selected comment encoding. HostLinkCommentEncoding::Cp932 means CP932/Windows-31J and is the selection for KEYENCE documentation that describes the compatible encoding as Shift_JIS; Rust does not expose a second strict-Shift-JIS variant. For cross-runtime consistency, this selection preserves ASCII control bytes, rejects standalone 80, A0, FD, FE, and FF, and accepts defined NEC, IBM, and duplicate CP932 extension pairs. Under Utf8, an initial EF BB BF is preserved as U+FEFF comment data rather than removed as a signature; the same byte sequence is invalid under Cp932.

Text decoding is strict. It removes only trailing ASCII space padding bytes (0x20) and fails with HostLinkError::Protocol when the selected codec cannot decode the remaining payload. It never retries the other codec or inserts replacement characters, and a malformed payload retires the connection. An exact, correctly framed PLC E0 through E9 response returns HostLinkError::Plc without retiring the connection, so a later command may reuse it. This applies to all semantic commands, including state-changing commands; malformed framing and malformed non-PLC payloads still retire it. Tabs, full-width spaces, other Unicode whitespace, and spaces inside the comment are preserved. read_comment_bytes excludes only the transport CR/LF terminator and returns the exact payload, including trailing ASCII spaces; use it whenever the application cannot assert the stored encoding.

PLC clock

set_time requires a HostLinkClock. It never substitutes the host clock. The year field is the PLC's two-digit year and must be in 0..=99. Calendar fields, real date existence, and weekday agreement are validated before transport. Setting the clock changes PLC state and elapsed time makes an exact automatic restore impossible. Run this only on a controlled PLC when replacing its clock with the host's local time is explicitly intended, then verify the PLC clock through the engineering environment.

let clock = plc_comm_kv_hostlink::HostLinkClock::now_local()?;
client.set_time(clock).await?;

Calling now_local is an explicit application choice. Failure to obtain the local offset is returned and is not replaced with UTC.

Shared clients and lifecycle

HostLinkClient, HostLinkClient::connect, and open_and_connect all use the same client type. Clones share one FIFO admission queue and one wire turn, so there is no separate queued wrapper or bypass alias. Arguments and timeout are snapshotted at admission. Dropping a future while it is still waiting removes it without a send and produces no library Result.

close rejects both the active operation and all operations waiting in the old connection generation. A later explicit open creates a new generation; work admitted before close cannot send on that reopened transport.

Errors and uncertain write outcomes

HostLinkError distinguishes Protocol, Timeout, Closed, NotConnected, Transport, Plc, and OutcomeUnknown. A state-changing command that may already have been sent returns OutcomeUnknown when timeout, close, transport failure, or malformed acknowledgement prevents a definite result. Raw commands are conservatively treated as state-changing. The client retires the affected TCP connection or UDP socket and never retries automatically.

Rust cancellation is future drop rather than a returned library error. Dropping a state-changing future after transmission may have started gives the caller no HostLinkError; the caller must treat the PLC outcome as unknown. TCP returns NotConnected until open succeeds. UDP drops the in-flight socket and creates a replacement from the resolved endpoint for the next command. This caller-observed cancellation is distinct from the library's Timeout result and is deliberately not a HostLinkOutcomeUnknownReason variant.

Traffic statistics

Call client.traffic_stats().await for cumulative request, transmitted-byte, and received-byte counts. For TCP, a received line counts its body plus the first CR/LF terminator; extra CR/LF separators are consumed but not counted. For UDP, the complete response datagram is counted.