docs: chapters 11–13 — HSMS, SECS-I, GEM

Three more chapters of Part 2:

11 — E37 HSMS.  4-byte length prefix + 10-byte header (R-bit + session
id + W-bit + stream + function + PType + SType + system_bytes), the
9 SType control messages, the NOT-SELECTED → SELECTED state machine,
T3/T5/T6/T7/T8 with what each one bounds, the auto-S9 paths
(S9F1/F3/F5/F7/F9/F11), HSMS-SS vs HSMS-GS, the asio
single-threaded contract.

12 — E4 SECS-I.  Half-duplex line turnaround (ENQ/EOT/ACK/NAK), the
10-byte block header bit-packing (R-bit / W-bit / E-bit / system
bytes), the 244-byte block cap and multi-block split/assemble, the
event-driven IO-free FSM with its Action / Event variants, T1/T2/T3/T4
with semantics + defaults, master/slave contention.  Notes the
deferred asio serial_port adapter; explains why this chapter
matters even for HSMS-only readers.

13 — E30 GEM.  Disambiguates the three state machines (HSMS transport
vs GEM communication vs GEM control), walks the comm-state FSM
(DISABLED → WAIT-CRA → COMMUNICATING with T_CRA / T_DELAY) and the
control-state FSM (5 states + the YAML transition table).  Lists
every Fundamental and Additional capability with its messages, code
locations, and store assignments.  One worked Event-Notification
scenario tracing seven on-wire steps to their EquipmentDataModel
internals.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-06-09 20:07:31 +02:00
parent 338d0b974d
commit 858ca22975
3 changed files with 962 additions and 0 deletions
+444
View File
@@ -0,0 +1,444 @@
# 11 — E37: HSMS — TCP transport for SECS-II
← [10 E5 — SECS-II data items](10_e5_secs_ii_data_items.md) | [Back to index](00_index.md) | Next: [12 E4 — SECS-I](12_e4_secs_i.md) →
You have an Item. You have its byte encoding from chapter 10. Now
those bytes need to travel from equipment to host (or vice versa).
**SEMI E37 — HSMS, High-Speed SECS Message Services** — published
1995, defines how SECS-II bytes travel over TCP/IP. It's not a
codec (chapter 10 was the codec); it's a framing + connection-state
+ timer protocol that sits between SECS-II and TCP.
This chapter covers:
- The 14-byte HSMS framing.
- The connection state machine (NOT-CONNECTED → NOT-SELECTED →
SELECTED).
- The five T-timers (T3, T5, T6, T7, T8) and what each one bounds.
- The control-message handshakes: Select.req, Linktest.req,
Separate.req, Reject.req.
- HSMS-SS (single-session) vs HSMS-GS (multi-session).
- The auto-S9 protocol-error replies the implementation emits.
- Where every part of this is in
[`include/secsgem/hsms/`](../include/secsgem/hsms/) and
[`src/hsms/`](../src/hsms/).
---
## The frame
Every HSMS message on the wire looks the same:
```
┌─────────────────┬──────────────────────────┬──────────────────────────┐
│ 4-byte length │ 10-byte header │ body (0+ bytes) │
│ big-endian │ session_id / byte2 / … │ SECS-II Item bytes │
└─────────────────┴──────────────────────────┴──────────────────────────┘
```
The length prefix counts the **header + body** bytes (it does *not*
include itself). So a frame on the wire is at least 14 bytes long
(4-byte length + 10-byte header, with an empty body — typical for
control messages).
The length is big-endian: the high byte first, then the next, etc.
Defined in [`include/secsgem/hsms/header.hpp:113`](../include/secsgem/hsms/header.hpp):
```cpp
inline constexpr std::size_t kLengthPrefixSize = 4;
inline constexpr std::size_t kHeaderSize = 10;
```
The body is a fully-encoded SECS-II Item (chapter 10). For control
messages (Select, Linktest, etc.) the body is empty. For data
messages it carries whatever Item the higher layer wants to send.
---
## The 10-byte header
The header is the only HSMS-specific structure besides the length
prefix. Six fields, packed in a fixed order:
```
byte 0 1 2 3 4 5 6 7 8 9
┌──────┬──────────┬──────────┬─────┬─────┬───────────┐
│ ses │ byte2 │ byte3 │ Ptype│ Stype│ system_bytes │
│ _id │ (W+strm) │ function │ (=0) │ │ (correlation)│
└──────┴──────────┴──────────┴─────┴─────┴───────────┘
↑ ↑ ↑ ↑ ↑ ↑
u16 bit7 W func id 0 SType u32
bits6-0
stream
```
Per field:
- **`session_id`** (u16, big-endian). For HSMS-SS this is the
sentinel `0xFFFF` (`kControlSessionId` in
[`header.hpp:48`](../include/secsgem/hsms/header.hpp)). For
HSMS-GS, it carries the device ID identifying which session this
frame belongs to.
- **`byte2`** (u8). Bit 7 is the **W-bit** (1 = reply expected,
0 = no reply). Bits 06 are the **stream** number (S1S127).
- **`byte3`** (u8). The **function** number (F0F255).
- **`PType`** (u8). Presentation type. Always 0 for SECS-II
bodies. Any other value triggers `S9F1` "Unrecognized Device ID"
(or `RejectReq` PtypeNotSupported, depending on state).
- **`SType`** (u8). Session type. 0 for data messages; 19 for the
various control messages (see next section).
- **`system_bytes`** (u32, big-endian). An opaque correlation
token. Picked by the sender; the receiver MUST echo the same
value in its reply. This is how we match a `S1F2` to the
`S1F1` that asked for it.
The header is encoded by [`Header::encode()`](../include/secsgem/hsms/header.hpp)
and decoded by [`Header::decode()`](../include/secsgem/hsms/header.hpp);
both implementations in
[`src/hsms/header.cpp`](../src/hsms/header.cpp) are ~30 lines each.
### Helpful constructors
The header has two named constructors that wire the right fields
together:
```cpp
// Data message: W-bit + stream + function + system_bytes.
Header::data_message(session_id, stream, function, w_bit, system_bytes);
// Control message: SType + system_bytes (other fields default).
Header::control(stype, system_bytes);
```
Both in [`header.hpp:74-97`](../include/secsgem/hsms/header.hpp).
### A worked frame
Encode the data message `S1F1 W` (Are You There, reply expected), in
HSMS-SS, with `system_bytes = 1`:
```
length prefix: 00 00 00 0A (header only = 10 bytes; body is empty)
session_id: FF FF (0xFFFF — kControlSessionId)
byte2: 81 (bit 7 W=1 | stream 1 = 0x80 | 0x01)
byte3: 01 (function 1)
PType: 00 (SECS-II)
SType: 00 (data)
system_bytes: 00 00 00 01 (correlation = 1)
```
That's a complete frame. Send those 14 bytes over TCP and you've
just asked an equipment "are you there?"
The reply, `S1F2`, would echo `system_bytes = 1` and carry a body
(the MDLN + SOFTREV `<L[2] A "MDLN" A "SOFTREV">`).
---
## The session types (`SType`)
Defined as
[`enum class SType`](../include/secsgem/hsms/header.hpp):
| SType | Name | Purpose |
|-------|----------------|---------------------------------------------------------------|
| 0 | `Data` | Carries a SECS-II message body. |
| 1 | `SelectReq` | Request to enter SELECTED state. |
| 2 | `SelectRsp` | Reply to Select.req. Body carries a 1-byte `SelectStatus`. |
| 3 | `DeselectReq` | Request to leave SELECTED state (rarely used). |
| 4 | `DeselectRsp` | Reply to Deselect.req. |
| 5 | `LinktestReq` | "Are you still there?" probe. |
| 6 | `LinktestRsp` | Reply to Linktest.req. |
| 7 | `RejectReq` | "I don't understand that." Sent in response to malformed control. |
| 9 | `SeparateReq` | "I'm closing the connection." No reply expected. |
SType 8 is reserved.
For every primary control message (Req types 1, 3, 5), the matching
Rsp type comes back — except for `RejectReq` and `SeparateReq`
which are fire-and-forget. All control messages use the
`kControlSessionId = 0xFFFF` in HSMS-SS.
---
## The connection state machine
A new TCP connection is `NOT-SELECTED`. Until both sides
SELECT, no data messages can be exchanged — even if data frames
arrive, they're rejected with `RejectReq(EntityNotSelected)`.
```
(TCP connected; T7 armed)
┌────────────────┐ Select.req → ┌────────────────┐
│ NOT-SELECTED │ ────────────────────────► │ SELECTED │
│ │ ← Select.rsp(Ok) │ │
└────────────────┘ └────────────────┘
│ │
│ T7 expires │ Separate.req → close
│ ─────────────────► close │ or TCP FIN
│ │
│ data frame received while NOT-SELECTED │
│ ─────────────────► RejectReq(EntityNotSelected)
│ │
└─────── close (any reason) ───────────────────┘
```
Per side:
- **Active** initiates the TCP connect, then sends `Select.req`.
- **Passive** binds and listens; T7 arms when the TCP connection
comes in; it waits for the peer's `Select.req`.
The state transitions live in
[`src/hsms/connection.cpp`](../src/hsms/connection.cpp) — `start()`
either calls `connect()` (active) or `accept()` (passive); reaching
SELECTED fires `on_selected_` and arms the linktest timer.
The convention for SECS/GEM is that **the equipment is passive**
(binds the port) and **the host is active** (initiates connect +
Select). This is configurable — `Connection::Mode` is `Active` or
`Passive` — but every example in this codebase follows the GEM
default.
---
## The five T-timers
The HSMS spec defines T1, T2, T3, T4, T5, T6, T7, T8 — but **HSMS
only uses T3, T5, T6, T7, T8**. T1, T2, T4 are SECS-I (chapter 12).
Defaults in
[`Timers` struct](../include/secsgem/hsms/header.hpp), line 51:
```cpp
struct Timers {
std::chrono::milliseconds t3{45000}; // reply
std::chrono::milliseconds t5{10000}; // connect separation
std::chrono::milliseconds t6{5000}; // control transaction
std::chrono::milliseconds t7{10000}; // not-selected
std::chrono::milliseconds t8{5000}; // intercharacter
std::chrono::milliseconds linktest{0}; // 0 disables
};
```
### T3 — reply timeout (45 s default)
When a side sends a W=1 data message, it arms T3 for the matching
reply. T3 cancels when the reply arrives (same system_bytes). If
it expires, the implementation **auto-emits `S9F9` Transaction Timer
Timeout** carrying the original 10-byte MHEAD so the peer knows
which transaction timed out.
Tested in [`tests/test_hsms_timers.cpp:164`](../tests/test_hsms_timers.cpp).
### T5 — connect separation timeout (10 s default)
After a TCP connection fails or drops, the active side waits T5
before retrying. Stops a misbehaving peer from getting hammered
with reconnects.
### T6 — control transaction timeout (5 s default)
For Select.req → Select.rsp, Linktest.req → Linktest.rsp, and
Deselect.req → Deselect.rsp. If the response doesn't come back
within T6, the connection closes.
Tested in [`tests/test_hsms_timers.cpp:206`](../tests/test_hsms_timers.cpp).
### T7 — not-selected timeout (10 s default)
On a passive side: when TCP comes up but no Select.req has arrived
yet, T7 is armed. If it expires before Select.req, the passive
side closes the connection. This prevents an unauthenticated
connection from camping forever.
Tested in [`tests/test_hsms_timers.cpp:230`](../tests/test_hsms_timers.cpp).
### T8 — inter-character timeout (5 s default)
After reading the 4-byte length prefix, the implementation waits up
to T8 for the next byte. If the peer goes silent mid-payload, the
connection closes. Protects against half-open connections that
think they're alive but never finish sending.
Tested in [`tests/test_hsms_timers.cpp:248`](../tests/test_hsms_timers.cpp).
### Linktest cadence
`Timers::linktest` (not a timeout — a cadence). If non-zero, the
side periodically emits `Linktest.req` to verify the peer is still
responsive. Default is 0 (disabled); turn it on when you suspect
silent drops. Linktest is the only HSMS health-check between
data messages.
---
## The auto-S9 paths
When the connection sees a *protocol* error — malformed bytes,
unknown SType, unhandled stream/function — it doesn't silently
drop. It emits an `S9F<n>` message back to the peer so the peer
knows what went wrong:
| Trigger | Reply | Where |
|------------------------------------------------------|-----------------|-----------------------------------------------|
| Unknown PType | `S9F1` | (auto) |
| Unrecognized device ID (SS mode with non-control sid) | `S9F1` | `Connection::handle_data` |
| Frame body fails SECS-II decode | `S9F7` | `Connection::handle_data` |
| W=1 message arrived for unknown stream | `S9F3` | Router::dispatch_with_s9 → `Connection::emit_s9` |
| W=1 message arrived for unknown function in known stream | `S9F5` | Router::dispatch_with_s9 → `Connection::emit_s9` |
| W=1 reply timed out (T3 expired) | `S9F9` | `Connection::on_t3_expire` |
| Body larger than configured cap | `S9F11` | `Connection::on_payload` (16 MiB-ish cap) |
| Frame valid but received in NOT-SELECTED | `RejectReq(EntityNotSelected)` | `handle_data` |
`emit_s9(function, mhead)` is exposed publicly
([`connection.hpp:97`](../include/secsgem/hsms/connection.hpp)) so a
higher-level dispatcher (like `gem::Router`) can call it after its
own unknown-stream / unknown-function check.
Tested across
[`tests/test_hsms_s9.cpp`](../tests/test_hsms_s9.cpp) (3 cases for
the malformed-body paths) and
[`tests/test_s9_fallback.cpp`](../tests/test_s9_fallback.cpp) (2
cases for the Router dispatch path).
---
## HSMS-SS vs HSMS-GS
### Single-Session (HSMS-SS)
The default. One TCP socket carries one SECS conversation between
one equipment and one host. The `session_id` field in every frame
header carries the sentinel `0xFFFF` (control session ID), even for
data messages — it's not used for routing.
In code: pass any value to `Connection`'s constructor as
`device_id` — it's stored, but for SS-mode handshakes the header's
session_id is forced to `0xFFFF`.
### General-Session (HSMS-GS)
E37 §11. One TCP socket multiplexes *several* sessions, each with
its own device_id. The Select.req frame's `session_id` field
carries the device_id of the session being selected; data frames
likewise carry the session's device_id in the header.
Use case: one equipment with one TCP socket to a host that wants
several logical conversations — say, production traffic on one
session, maintenance traffic on another. Real-world example: a
fab where the production MES and the maintenance MES connect to
the same equipment over one link via separate gateways.
In code:
```cpp
auto conn = std::make_shared<Connection>(std::move(sock), Mode::Passive,
/*primary device_id=*/100, timers);
conn->add_session(/*device_id=*/200); // second session
conn->add_session(/*device_id=*/300); // third session
conn->set_session_message_handler(100, ...);
conn->set_session_message_handler(200, ...);
conn->set_session_message_handler(300, ...);
conn->start();
```
Each session has its own SELECTED state, independent message
handler, independent send queue. When a Select.req arrives, the
implementation routes it by `session_id` field to find the right
registered session.
Tested in
[`tests/test_hsms_gs.cpp`](../tests/test_hsms_gs.cpp) (5 wire-level
cases) and
[`tests/test_hsms_gs_integration.cpp`](../tests/test_hsms_gs_integration.cpp)
(one end-to-end three-session scenario).
Implementation walk-through with code snippets:
[`docs/INTEGRATION.md`](INTEGRATION.md) §7.
---
## The single-threaded contract
`Connection` is **single-threaded**. All state mutations — the
read loop, the send queue, the timers, the SELECTED transition —
run on the socket's `asio::executor`. Callers that want to send
from another thread must marshal onto the executor via
`asio::post()`.
The contract is documented in
[`docs/INTEGRATION.md`](INTEGRATION.md) §3 and exercised under
ThreadSanitizer by
[`tests/test_thread_safety.cpp`](../tests/test_thread_safety.cpp).
N producer threads `asio::post` updates; TSan reports zero races.
This avoids every mutex you'd otherwise need for the queue, the
session map, the in-flight request table, the timer state machine.
The cost is that the caller has to know about it. See chapter
[33](33_transport.md) for a deeper look at the asio strand model.
---
## How send/receive feels from the caller
Send a request (W=1):
```cpp
conn->send_request(secs2::Message{stream, function, /*w=*/true, body},
[](std::error_code ec, const secs2::Message& reply) {
if (ec) {
// T3 expired, or connection closed before reply.
} else {
// reply.body() is the decoded Item.
}
});
```
Send a one-way data message (W=0):
```cpp
conn->send_data(secs2::Message{6, 11, /*w=*/false, event_report_body});
```
Register a primary-message handler (called for inbound W=1 messages
in SELECTED state):
```cpp
conn->set_message_handler([](const secs2::Message& m) -> std::optional<secs2::Message> {
// Build a reply Message and return it; system_bytes are auto-filled.
return secs2::Message{m.stream(), m.function() + 1, /*w=*/false, reply_body};
});
```
Higher layers (`gem::Router`) wrap this with stream/function dispatch.
---
## What's still ahead at this layer
The connection is **just transport**. It doesn't know about GEM
states, doesn't know about CEIDs or alarms, doesn't validate the
body's shape (just decodes it as an Item). When `gem::Router`
hands a request to the alarm handler and the alarm handler returns
a reply, all the Router does is bundle that reply into a
`secs2::Message` and tell the connection to send it.
Three things to revisit in later chapters:
- **The behavioural layer above HSMS** is E30 (chapter
[13](13_e30_gem.md)). GEM has its own communication state machine
on top of HSMS's transport state machine — they look similar but
are *different* state machines.
- **The Router** that dispatches stream/function to handlers lives
in `secsgem::gem::Router` and is covered in chapter
[35](35_state_machines_and_dispatch.md).
- **The asio strand model** that makes the single-threaded contract
work is in chapter [33](33_transport.md).
But first, the *other* transport — the one this one replaced, but
that hasn't gone away. **SECS-I over serial**.
Next: [→ 12 E4 — SECS-I: the serial origin](12_e4_secs_i.md)
+259
View File
@@ -0,0 +1,259 @@
# 12 — E4: SECS-I — the serial origin
← [11 E37 — HSMS transport](11_e37_hsms.md) | [Back to index](00_index.md) | Next: [13 E30 — GEM](13_e30_gem.md) →
HSMS (chapter 11) is what every modern 300 mm tool runs. But SECS-I
— published 1980, the *original* SECS transport — is still on the
wire. Older 200 mm fabs, smaller specialty tools (e.g. inspection
microscopes, simple metrology), some legacy lithography steppers,
even some new equipment shipping into mixed-fleet fabs all speak
SECS-I over RS-232 or RS-422.
This chapter is short, because the protocol is small. By the end:
- You'll understand half-duplex line turnaround.
- You'll know the ENQ / EOT / ACK / NAK handshake.
- You'll be able to read the 10-byte block header.
- You'll know exactly what's implemented here, what isn't, and
why.
---
## What SECS-I actually is
A **half-duplex block protocol** for RS-232 / RS-422 serial links.
Half-duplex means: at any moment, exactly one side is allowed to be
transmitting. Switching direction requires an explicit handshake.
The handshake uses four single-byte control codes:
| Byte | Mnemonic | Meaning |
|------|----------|----------------------|
| 0x05 | `ENQ` | I want to send |
| 0x04 | `EOT` | Go ahead, send |
| 0x06 | `ACK` | Block received OK |
| 0x15 | `NAK` | Block bad, retry |
A successful transmission looks like:
```
sender receiver
────── ────────
ENQ ──────────────► "I want to send"
◄────────── EOT "go ahead"
<block bytes> ────► "here you go"
◄────────── ACK "got it"
```
Compare to HSMS: HSMS gets all this for free from TCP. TCP is
full-duplex, segments are framed by the operating system, and the
ACK semantics are at the byte level not the message level. SECS-I
predates that affordance — it was designed for a UART straight on
the equipment's serial port.
---
## The 10-byte block header
A block carries the same logical information as an HSMS data
message — session, stream, function, W-bit, system bytes — packed
slightly differently:
```
byte 0 1 2 3 4 5 6 7 8 9
┌──────┬──────────┬──────────┬────────┬───────────┐
│R+sid │ W+stream │ function │E+block#│ sys bytes │
└──────┴──────────┴──────────┴────────┴───────────┘
u16 bit7 W func id bit15 E u32
bit15 R bits6-0 (byte 3) bits14-0 (BE)
bits14-0 stream block #
device id
```
Three bit-packings, all in [`Header::encode/decode`](../include/secsgem/secsi/header.hpp):
- **Byte 01**: R-bit (bit 15) + 15-bit device ID. `R=1` means
"host → equipment", `R=0` means "equipment → host".
- **Byte 2**: W-bit (bit 7) + 7-bit stream. Same W-bit semantics as
HSMS.
- **Byte 45**: E-bit (bit 15) + 15-bit block number. `E=1` marks
the *last* block of a multi-block message. Block numbers are
1-based.
- **Byte 69**: 32-bit `system_bytes` correlation token.
Defined in [`include/secsgem/secsi/header.hpp`](../include/secsgem/secsi/header.hpp).
---
## Multi-block messages
A single SECS-I block carries at most **244 bytes** of body:
```cpp
// include/secsgem/secsi/block.hpp
inline constexpr std::size_t kMaxBlockBody = 244;
```
Why 244? The framing uses a one-byte length field that counts
10 (header) + body, with byte values 10254 valid (255 reserved).
254 10 = 244.
A SECS-II body larger than 244 bytes is **split into multiple
blocks**, each with the same header except for the incrementing
block number and the E-bit (set only on the last block).
[`secsi::split_message`](../include/secsgem/secsi/block.hpp) does
the split; [`secsi::assemble_message`](../include/secsgem/secsi/block.hpp)
recombines them.
Each block has a 2-byte checksum after the body — sum of every
byte in the header + body modulo `2^16`, big-endian.
Multi-block round-trip is verified by [`tests/test_secsi.cpp`](../tests/test_secsi.cpp)
(15 cases) and [`tests/test_secsi_tcp.cpp`](../tests/test_secsi_tcp.cpp)
(3 cases — an end-to-end split / send / reassemble over the test
TCP transport).
> **Why HSMS doesn't need this.** HSMS frames have a 4-byte length
> prefix, so a single frame can carry up to 4 GiB. Multi-block is
> a SECS-I concept that simply doesn't apply on TCP.
---
## The line-turnaround FSM
The interesting part of SECS-I — and the part that bites every
implementer — is the **half-duplex** state machine. Both sides
might want to send at the same time. Both might `ENQ`
simultaneously. Both must agree on who yields.
E4 §7.1.4: **the master holds, the slave yields**. By convention
the host is master and the equipment is slave, but this is
configurable. In code:
```cpp
// include/secsgem/secsi/protocol.hpp
enum class Role { Master, Slave };
```
The FSM is event-driven and IO-free. It takes:
- **`EventByte`** — one received byte.
- **`EventSend`** — the application wants to send a block.
- **`EventTimeout`** — a previously-armed timer fired.
And produces a sequence of actions:
- **`ActionTransmit`** — push these bytes onto the wire.
- **`ActionStartTimer`** / **`ActionCancelTimer`** — arm or
cancel one of T1/T2/T3/T4.
- **`ActionDeliverBlock`** — pass this received block up to the
application.
- **`ActionRaiseError`** — fatal: retries exhausted, line
protocol violated, etc.
State names from [`Protocol::State`](../include/secsgem/secsi/protocol.hpp):
```
Idle ──ENQ─► SendEnqSent ──EOT─► SendBlock ──bytes─► WaitAck ──ACK─► Idle
└─NAK─► retry (RTY budget)
Idle ──ENQ(rx)─► RecvEnq ──EOT(tx)─► RecvBlock ──bytes(rx)─► RecvAck ──ACK(tx)─► Idle
└─bad checksum─► NAK(tx) → RecvBlock (retry)
```
Tests in [`tests/test_secsi.cpp`](../tests/test_secsi.cpp) and
[`tests/test_secsi_timers.cpp`](../tests/test_secsi_timers.cpp)
walk every transition.
---
## The four SECS-I T-timers
Distinct from HSMS T-timers despite the name overlap:
| Name | Default | Bounds |
|------|---------|-------------------------------------------------|
| T1 | 500 ms | Inter-character — gap between bytes in one block |
| T2 | 10 s | Protocol — waiting for EOT after our ENQ, or vice versa |
| T3 | 45 s | Reply — primary (W=1) waiting for the reply block |
| T4 | 45 s | Inter-block — gap between blocks of a multi-block message |
Defaults in
[`secsi::Timers`](../include/secsgem/secsi/protocol.hpp):
```cpp
struct Timers {
std::chrono::milliseconds t1{500};
std::chrono::milliseconds t2{10000};
std::chrono::milliseconds t3{45000};
std::chrono::milliseconds t4{45000};
uint8_t rty = 3;
};
```
Each timer is armed by the FSM via `ActionStartTimer`, cancelled
by `ActionCancelTimer`, and fired by the wrapping host's wall
clock. The FSM itself has no wall clock — it only sees
`EventTimeout` when the host tells it the timer fired.
Tested independently in
[`tests/test_secsi_timers.cpp`](../tests/test_secsi_timers.cpp)
(9 cases — every armed-and-cancelled scenario, every expiry).
---
## What's implemented here vs. what isn't
Per [docs/COMPLIANCE.md](COMPLIANCE.md) §1a:
| Item | Status |
|--------------------------------------------|--------|
| 10-byte block header bit-packing/unpacking | ✅ |
| Length-prefixed block + 2-byte checksum | ✅ |
| Multi-block split / assemble (E-bit, block#) | ✅ |
| ENQ/EOT/ACK/NAK half-duplex handshake | ✅ |
| RTY retry budget | ✅ |
| T1/T2/T3/T4 timer hooks (event-driven) | ✅ |
| Master/slave contention resolution | ✅ |
| TCP tunnel for testing | ✅ |
| **Serial-port driver (asio `serial_port`)** | **⬜ deferred** |
The FSM is complete and tested end-to-end **over a TCP transport**:
[`secsi::TcpTransport`](../include/secsgem/secsi/tcp_transport.hpp)
wraps the FSM behind an asio TCP socket. This is enough for
testing and for the docker-compose interop flows, but it's not a
real serial port.
The remaining piece — a serial driver that pumps bytes between
the FSM and an `asio::serial_port` — has not been written. Most
modern GEM equipment runs HSMS; the deferral is documented in
the README "Deferred follow-ups" section. Mirror `TcpTransport`
to add it.
---
## Why this matters even if you only run HSMS
Two reasons to read this chapter even if you'll never touch serial:
1. **The line-turnaround FSM informs the GEM communication state
machine.** E30 §6.5 reuses the establish-comms pattern that
originated in SECS-I — T_CRA / T_DELAY echo T3 / T2. See
chapter [13](13_e30_gem.md).
2. **Block-level error recovery is a useful mental model.** Even
on HSMS, the per-message correlation by `system_bytes` and the
T3 reply timer are direct descendants of SECS-I's block-level
tracking. Understanding one helps you read the other.
---
## Where to go next
Now you know both transports. Chapter [13](13_e30_gem.md) lifts up
one level: **E30 — GEM behaviour**. This is where the protocol
stops being plumbing and starts encoding *what equipment is
supposed to do*: communication state, control state, the GEM
Fundamental + Additional capabilities, scenarios for every
typical interaction.
Next: [→ 13 E30 — GEM behaviour](13_e30_gem.md)
+259
View File
@@ -0,0 +1,259 @@
# 13 — E30: GEM — the behavioural model
← [12 E4 — SECS-I](12_e4_secs_i.md) | [Back to index](00_index.md) | Next: [14 E40 + E94 — Process and control jobs](14_e40_e94_jobs.md) →
E5 (chapter 10) is the data encoding. E37 / E4 (chapters 1112)
move the encoded bytes. This chapter is the first one where
*behaviour* enters the picture.
**SEMI E30 — Generic Equipment Model (GEM)**, published 1992,
specifies what an equipment must *do* when its host sends specific
messages. E5 says how to encode S1F13; E30 says what state must
change when an S1F13 arrives, what reply must come back, and under
what conditions either side may refuse.
E30 has two top-level concepts:
1. **Two state machines** — communication state (above HSMS) and
control state (governs who's allowed to issue commands).
2. **GEM Capabilities** — Fundamentals (mandatory) and Additionals
(optional but de-facto required). Each capability defines its
own scenarios + messages.
By the end of this chapter you'll know both state machines, the
14 Fundamentals + Additionals, and where each one lives in code.
---
## The two GEM state machines
GEM has **two** state machines that live on top of HSMS's transport
state machine. Read carefully — beginners conflate these all the
time:
| State machine | Lives where | Concerns |
|----------------------|---------------------------------------------------|-----------------------------------------------|
| **HSMS transport** | `secsgem::hsms::Connection` | NOT-CONNECTED → NOT-SELECTED → SELECTED |
| **GEM communication**| `secsgem::gem::CommunicationStateMachine` | DISABLED / WAIT-CRA / WAIT-DELAY / COMMUNICATING |
| **GEM control** | `secsgem::gem::ControlStateMachine` | EquipmentOffline / OnlineLocal / OnlineRemote / … |
All three can be in independent states. `SELECTED` (HSMS) doesn't
imply `COMMUNICATING` (GEM-comm); `COMMUNICATING` doesn't imply
`OnlineRemote` (control).
### Communication state (E30 §6.5)
What it answers: **have host and equipment agreed they can talk to
each other at the GEM level?** This is *above* HSMS — even after
HSMS is SELECTED, GEM-comm starts at WAIT-CRA and only reaches
COMMUNICATING after a successful `S1F13 / S1F14 (COMMACK=Accept)`
exchange.
```
wire: S1F13 →
DISABLED ─enable──► WAIT-CRA ─────────────► COMMUNICATING
│ ◄─ S1F14(Accept)
│ S1F14(Deny) or T_CRA expires
WAIT-DELAY
│ T_DELAY expires
WAIT-CRA (retry)
```
Two timers, both in `gem::CommunicationStateMachine`:
- **T_CRA** (default 45 s): how long to wait for the S1F14 reply
after sending S1F13.
- **T_DELAY** (default 10 s): how long to back off after a
rejected S1F14 before retrying.
Code:
[`include/secsgem/gem/communication_state.hpp`](../include/secsgem/gem/communication_state.hpp);
tests in
[`tests/test_communication_state.cpp`](../tests/test_communication_state.cpp)
(12 cases — every transition, every timer expiry).
The state machine is **IO-free** — it raises actions (send S1F13,
arm T_CRA, …) that the caller translates into asio work. This
makes it unit-testable without spinning up a TCP socket. Same
design pattern as `secsi::Protocol` from chapter 12.
### Control state (E30 §6.2)
What it answers: **who's allowed to issue commands right now?**
Five states:
| State | Meaning |
|------------------|------------------------------------------------------------|
| `EquipmentOffline` | Off-network. Both panel and host commands disabled. |
| `AttemptOnline` | Transient: equipment is dialing host. Rare. |
| `HostOffline` | Host disconnected (or never connected). Operator can act, host cannot. |
| `OnlineLocal` | Operator at the local panel has control. Host can read, not act. |
| `OnlineRemote` | Host has full control. |
Defined in
[`include/secsgem/gem/control_state.hpp`](../include/secsgem/gem/control_state.hpp).
Transitions are driven by **events** (operator pressed Online,
host sent S1F17, AttemptOnline succeeded or failed, …) and
encoded as a transition table loaded from
[`data/control_state.yaml`](../data/control_state.yaml):
```yaml
# data/control_state.yaml
transitions:
- {from: EquipmentOffline, on: operator_switch_online, to: AttemptOnline, then: OnlineRemote}
- {from: OnlineRemote, on: host_request_offline, to: HostOffline, ack: Accept}
- {from: OnlineLocal, on: host_request_remote, ack: NotAccept}
...
```
The table is **pure data**. `ControlTransitionTable` looks up
rows; `ControlStateMachine` applies them. No `if/else` ladders
embedded in C++.
```cpp
// include/secsgem/gem/control_state.hpp:53
struct ControlTransition {
ControlState from;
ControlEvent on;
std::optional<ControlState> to;
std::optional<ControlState> then; // chain through AttemptOnline
std::optional<uint8_t> ack_code;
};
```
This is **spec-as-data** in its purest form: the SEMI standard
section 6.2 is one YAML file. Add a state, add a transition, edit
the YAML — no recompile, no C++ change. See chapter
[31](31_spec_as_data_and_codegen.md) for the wider story.
Tests: [`tests/test_control_state.cpp`](../tests/test_control_state.cpp)
(15 cases — every YAML-defined transition, both ACK codes).
---
## GEM Fundamentals (E30 §5.2)
The **mandatory** capabilities. An equipment that doesn't ship
these isn't GEM-compliant, end of story.
| Fundamental | Messages | Code |
|---------------------------------------------|---------------------------------------------------------|---------------------------------------------------------------|
| State models | — | `ControlStateMachine`, `CommunicationStateMachine` |
| Equipment Processing States | — | `ControlTransitionTable` (vendor supplies concrete states) |
| Host-Initiated S1F13/F14 | S1F13 / S1F14 | `gem::CommunicationStateMachine` |
| Event Notification | S6F11 / S6F12 | `EventStore` + `EquipmentDataModel::compose_reports_for` |
| On-Line Identification | S1F1 / S1F2 | Router handler in `apps/secs_server.cpp` |
| Error Messages | S9F1/F3/F5/F7/F9/F11 | `Connection::emit_s9` + `Router::dispatch_with_s9` |
| Documentation | S1F19/F20, S1F21/F22, S1F23/F24 | `gem::compliance` / namelist handlers |
| Control (Operator-Initiated) | — | `ControlStateMachine::operator_online/offline/local/remote` |
Full per-capability accounting with status + spec section + code
ref: [docs/COMPLIANCE.md](COMPLIANCE.md) §3.
---
## GEM Additionals (E30 §5.3)
The **optional** capabilities — but every commercial MES will
require all of them. In practice "Additional" means "optional per
the SEMI spec, but mandatory for procurement."
| Additional | Messages | Code |
|---------------------------------------|---------------------------------------------------------------------------|-----------------------------------------------|
| Establish Communications | S1F13/F14 | `CommunicationStateMachine` (also in Fundamentals) |
| Dynamic Event Report Configuration | S2F33/F34, S2F35/F36, S2F37/F38 | `ReportStore`, `EventStore` |
| Variable Data Collection | S1F21/F22 + DVID values via `vid_value` | `DataVariableStore` |
| Trace Data Collection | S2F23/F24, S6F1/F2 | `TraceStore` |
| Status Data Collection | S1F3/F4, S1F11/F12 | `SvidStore` |
| Alarm Management | S5F1/F2, S5F3/F4, S5F5/F6, S5F7/F8 | `AlarmStore`, `AlarmDispatcher` |
| Remote Control | S2F41/F42, S2F49/F50, S2F21/F22 | `HostCommandRegistry` |
| Equipment Constants | S2F13/F14, S2F15/F16, S2F29/F30 | `EquipmentConstantStore` |
| Process Program Management | S7F1F6, S7F17F20, S7F23F26 | `RecipeStore` |
| Material Movement | (handled by E40 + E94 + E87 + E90 + E157) | see chapters 1416 |
| Equipment Terminal Services | S10F1/F2, S10F3/F4, S10F5/F6 | `TerminalServiceStore` |
| Clock | S2F17/F18, S2F31/F32 | `ClockStore` (+ E148 in chapter 19) |
| Limits Monitoring | S2F45/F46, S2F47/F48 | `LimitMonitorStore` |
| Spooling | S2F43/F44, S6F23/F24, S6F25/F26 | `SpoolStore` (persistent file-backed journal) |
Every capability has its **own store** (a namespace bundle of
state + behaviour) and its **own Router handlers** for the messages
that drive it. Stores compose into `EquipmentDataModel`. Chapter
[32](32_stores_and_the_data_model.md) is the deep dive.
---
## How a typical scenario lands in code
Pick **Event Notification** — the canonical GEM scenario:
```
1. Host sends S2F33 (DefineReport): "RPTID 100 = [SVID 1, SVID 5]"
2. Equipment stores the definition in ReportStore; replies S2F34(DRACK=0).
3. Host sends S2F35 (LinkEvent): "CEID 300 → RPTID 100"
4. Equipment stores the link in EventStore; replies S2F36(LRACK=0).
5. Host sends S2F37 (EnableEvent CEED=true, CEID=[300])
6. Equipment marks CEID 300 enabled in EventStore; replies S2F38(ERACK=0).
7. Later: some FSM transition decides to fire CEID 300.
compose_reports_for(300) walks EventStore → ReportStore → SvidStore
and assembles {RPTID=100, V=[svid1_val, svid5_val]}.
8. Equipment emits S6F11 with the assembled body.
9. Host replies S6F12(ACKC6=0).
```
Steps 1, 3, 5 are inbound — `gem::Router` dispatches by
`(stream, function)` to a registered handler. Steps 2, 4, 6, 8
are outbound — the handler or the FSM hands a built `secs2::Message`
to the Connection. Step 7 is *internal* — the EquipmentDataModel
walks its own stores; nothing on the wire happens until step 8.
Router and dispatch is in chapter
[35](35_state_machines_and_dispatch.md); store internals in
chapter [32](32_stores_and_the_data_model.md).
---
## The host-side analogue
Everything above describes the equipment side. The host side has
its own E30 state — every Additional capability has a host-side
view too (the host can disable an alarm, change a host command,
etc.). This codebase implements the host-side as a thin module:
```cpp
// include/secsgem/gem/host_handler.hpp
class HostHandler {
// Decode equipment-initiated S5F1 / S6F11 / S9Fx.
// Maintain the host's view of CEID enables, alarm enables, …
};
```
`apps/secs_client.cpp` is the canonical host binary. In the
two-container demo it walks ~24 transactions against
`apps/secs_server.cpp` — the host side mostly *reads* what the
equipment reports and acknowledges. Driving an MES is a much
bigger story (see chapter [41](41_integration_hardware_mes_production.md)).
---
## Where to go next
You now have:
- E5 codec.
- E37/E4 transport.
- E30 state machines and capabilities.
That's the complete **base GEM stack**. Modern fab automation
needs more — process job lifecycles, carrier management, substrate
tracking — and that's what **GEM 300** adds.
The next six chapters tackle the GEM 300 standards one family at a
time. Each one fits on top of E30 in the same way: a state
machine + a store + Router handlers + per-CEID emissions.
Next: [→ 14 E40 + E94 — Process and control jobs](14_e40_e94_jobs.md)