Chapter 17 — Train Control Protocol
The Train Control Protocol handles speed, function, controller management, consist
(listener) operations, and heartbeat monitoring for train nodes. It uses two MTIs:
MTI_TRAIN_PROTOCOL (commands to a train) and MTI_TRAIN_REPLY
(replies from a train). The handler automatically updates train state, builds replies,
and forwards consist commands to listeners.
src/openlcb/protocol_train_handler.c,
src/openlcb/protocol_train_handler.h
17.1 train_state_t Structure
Each train node has a mutable train_state_t structure allocated from a
static pool. It holds the complete runtime state:
typedef struct train_state_TAG {
uint16_t set_speed; // Last commanded speed (float16)
uint16_t commanded_speed; // Control algorithm output (float16)
uint16_t actual_speed; // Measured speed, optional (float16)
bool estop_active; // Point-to-point E-stop
bool global_estop_active; // Global Emergency Stop
bool global_eoff_active; // Global Emergency Off
node_id_t controller_node_id; // Active controller (0 if none)
uint8_t reserved_node_count; // Reservation count
uint32_t heartbeat_timeout_s; // Heartbeat deadline (seconds)
uint32_t heartbeat_counter_100ms; // Heartbeat countdown (100ms ticks)
train_listener_entry_t listeners[]; // Consist listener array
uint8_t listener_count;
uint8_t listener_enum_index; // For consist forwarding
uint16_t functions[]; // Function values by number
uint16_t dcc_address; // DCC address (0 = not set)
bool is_long_address; // Extended DCC address
uint8_t speed_steps; // 0=default, 1=14, 2=28, 3=128
struct openlcb_node_TAG *owner_node; // Back-pointer to owning node
} train_state_t;
17.2 Sub-Command Table
The first payload byte of MTI_TRAIN_PROTOCOL identifies the command
category. The P bit (0x80) indicates a proxied/forwarded command from a consist
member:
| Byte 0 | Command | Description |
|---|---|---|
| 0x00 | Set Speed/Direction | Set train speed using float16 encoding. |
| 0x01 | Set Function | Set a function value by 24-bit address. |
| 0x02 | Emergency Stop | Point-to-point E-stop for this train. |
| 0x10 | Query Speeds | Request set/commanded/actual speed values. |
| 0x11 | Query Function | Request function value by address. |
| 0x20 | Controller Config | Controller assign/release/query/changed (sub-command in byte 1). |
| 0x30 | Listener Config | Listener attach/detach/query (sub-command in byte 1). |
| 0x40 | Management | Reserve/release/heartbeat (sub-command in byte 1). |
Controller Sub-Commands (byte 1, when byte 0 = 0x20)
| Byte 1 | Operation |
|---|---|
| 0x01 | Controller Assign |
| 0x02 | Controller Release |
| 0x03 | Controller Query |
| 0x04 | Controller Changed Notify |
Listener Sub-Commands (byte 1, when byte 0 = 0x30)
| Byte 1 | Operation |
|---|---|
| 0x01 | Listener Attach |
| 0x02 | Listener Detach |
| 0x03 | Listener Query |
Management Sub-Commands (byte 1, when byte 0 = 0x40)
| Byte 1 | Operation |
|---|---|
| 0x01 | Reserve |
| 0x02 | Release |
17.3 Speed: Float16 Encoding
Speed values use the OpenLCB float16 format (IEEE 754 half-precision). The sign bit (bit 15) encodes direction: 0 = forward, 1 = reverse. The remaining 15 bits encode the speed magnitude as a half-precision float.
The handler stores the float16 value directly in train_state.set_speed.
The application can also maintain commanded_speed (control algorithm
output) and actual_speed (measured) for Query Speeds replies.
17.4 Function Control
Functions are identified by a 24-bit address (3 bytes starting at payload offset 1).
The function value is a 16-bit word at offset 4. Standard functions (0-28) are stored
in the train_state.functions[] array. Functions outside the array size
are still forwarded via the callback but not stored.
17.5 Controller Assignment Flow
The on_controller_assign_request decision callback lets the application
decide whether to allow the takeover. If the callback is NULL, the assignment is
always accepted. On rejection, the reply includes the 6-byte Node ID of the current
controller so the requester can negotiate a handoff.
17.6 Consist System (Listeners)
The consist system uses a listener list on each train node. When a speed or function command arrives, the handler processes it locally and then forwards it to all attached listeners using the enumerate flag pattern:
Listener Flags
| Flag | Value | Effect |
|---|---|---|
| REVERSE | 0x02 | Flip the direction bit in forwarded speed commands. |
| LINK_F0 | 0x04 | Forward function 0 (headlight) commands to this listener. |
| LINK_FN | 0x08 | Forward function 1+ commands to this listener. |
| HIDE | 0x80 | Hide this listener from enumeration queries. |
Forwarded commands have the P bit (0x80) set in byte 0 to indicate they originated from a consist master, not directly from a throttle. The forwarding logic skips the originating source node to prevent feedback loops, and checks LINK_F0/LINK_FN flags before forwarding function commands.
17.7 Heartbeat
The train node can send periodic heartbeat requests to its assigned controller. If the
controller does not respond within the timeout period, the
on_heartbeat_timeout notifier fires and the application can take
appropriate action (e.g., stop the train).
The heartbeat countdown is maintained in train_state.heartbeat_counter_100ms
and decremented by the application's 100ms tick handler. The timeout period in
seconds is stored in train_state.heartbeat_timeout_s.
17.8 Emergency Events
The handler processes well-known emergency Event IDs via
ProtocolTrainHandler_handle_emergency_event(). Emergency states are
tracked in the train state:
| Event | State Flag | Scope |
|---|---|---|
| Emergency Stop (point-to-point) | estop_active | Single train |
| Global Emergency Stop | global_estop_active | All trains |
| Global Emergency Off | global_eoff_active | All trains (power off) |
When an emergency event is received, the handler sets the corresponding flag and fires
on_emergency_entered. When the clear event is received, the flag is
reset and on_emergency_exited fires.
17.9 Callback Interface
The interface_protocol_train_handler_t contains three categories of
callbacks, all optional (NULL is safely ignored):
Train-Node Side: Notifiers
Fire AFTER the handler has updated train_state and built any reply.
The application uses these to drive hardware:
| Callback | When Fired |
|---|---|
on_speed_changed | Speed was set (state updated). |
on_function_changed | Function was set (state updated). |
on_emergency_entered | Emergency state activated. |
on_emergency_exited | Emergency state cleared. |
on_controller_assigned | Controller assigned or changed. |
on_controller_released | Controller released. |
on_listener_changed | Listener list modified (attach/detach). |
on_heartbeat_timeout | Heartbeat timed out. |
Train-Node Side: Decision Callbacks
Return a value the handler uses to build the reply. If NULL, the handler uses a default policy (accept):
| Callback | Purpose | Default (NULL) |
|---|---|---|
on_controller_assign_request | Another controller wants to take over. | Accept |
on_controller_changed_request | Controller Changed Notify received. | Accept |
Throttle Side: Notifiers
Fire when a reply is received from the train node:
| Callback | When Fired |
|---|---|
on_query_speeds_reply | Query Speeds reply received. |
on_query_function_reply | Query Function reply received. |
on_controller_assign_reply | Controller Assign reply received. |
on_controller_query_reply | Controller Query reply received. |
on_controller_changed_notify_reply | Controller Changed Notify reply received. |
on_listener_attach_reply | Listener Attach reply received. |
on_listener_detach_reply | Listener Detach reply received. |
on_listener_query_reply | Listener Query reply received. |
on_reserve_reply | Reserve reply received. |
on_heartbeat_request | Heartbeat request from train node. |