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.

Source files: 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 0CommandDescription
0x00Set Speed/DirectionSet train speed using float16 encoding.
0x01Set FunctionSet a function value by 24-bit address.
0x02Emergency StopPoint-to-point E-stop for this train.
0x10Query SpeedsRequest set/commanded/actual speed values.
0x11Query FunctionRequest function value by address.
0x20Controller ConfigController assign/release/query/changed (sub-command in byte 1).
0x30Listener ConfigListener attach/detach/query (sub-command in byte 1).
0x40ManagementReserve/release/heartbeat (sub-command in byte 1).

Controller Sub-Commands (byte 1, when byte 0 = 0x20)

Byte 1Operation
0x01Controller Assign
0x02Controller Release
0x03Controller Query
0x04Controller Changed Notify

Listener Sub-Commands (byte 1, when byte 0 = 0x30)

Byte 1Operation
0x01Listener Attach
0x02Listener Detach
0x03Listener Query

Management Sub-Commands (byte 1, when byte 0 = 0x40)

Byte 1Operation
0x01Reserve
0x02Release

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

sequenceDiagram participant Throttle as New Throttle participant Train as Train Node participant Current as Current Controller Throttle->>Train: Controller Assign (Node ID) alt No current controller Note over Train: Accept immediately Train->>Train: Store controller_node_id Train-->>Throttle: Controller Assign Reply (result=0x00) else Current controller exists alt Decision callback accepts Train->>Train: Store new controller_node_id Train-->>Throttle: Controller Assign Reply (result=0x00) else Decision callback rejects Train-->>Throttle: Controller Assign Reply (result=0x01, current controller ID) end end

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:

flowchart TD A["Speed/Function command\narrives at Train Node"] --> B["Update local train_state"] B --> C["Fire notifier callback"] C --> D{{"Any listeners?"}} D -- "No" --> E["Done"] D -- "Yes" --> F["Set enumerate flag\nReset listener_enum_index"] F --> G["Forward to listener[0]"] G --> H["Main loop re-dispatches"] H --> I["Forward to listener[1]"] I --> J["... until all done"] J --> K["Clear enumerate flag"] style A fill:#4a90d9,color:#fff

Listener Flags

FlagValueEffect
REVERSE0x02Flip the direction bit in forwarded speed commands.
LINK_F00x04Forward function 0 (headlight) commands to this listener.
LINK_FN0x08Forward function 1+ commands to this listener.
HIDE0x80Hide 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:

EventState FlagScope
Emergency Stop (point-to-point)estop_activeSingle train
Global Emergency Stopglobal_estop_activeAll trains
Global Emergency Offglobal_eoff_activeAll 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:

CallbackWhen Fired
on_speed_changedSpeed was set (state updated).
on_function_changedFunction was set (state updated).
on_emergency_enteredEmergency state activated.
on_emergency_exitedEmergency state cleared.
on_controller_assignedController assigned or changed.
on_controller_releasedController released.
on_listener_changedListener list modified (attach/detach).
on_heartbeat_timeoutHeartbeat 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):

CallbackPurposeDefault (NULL)
on_controller_assign_requestAnother controller wants to take over.Accept
on_controller_changed_requestController Changed Notify received.Accept

Throttle Side: Notifiers

Fire when a reply is received from the train node:

CallbackWhen Fired
on_query_speeds_replyQuery Speeds reply received.
on_query_function_replyQuery Function reply received.
on_controller_assign_replyController Assign reply received.
on_controller_query_replyController Query reply received.
on_controller_changed_notify_replyController Changed Notify reply received.
on_listener_attach_replyListener Attach reply received.
on_listener_detach_replyListener Detach reply received.
on_listener_query_replyListener Query reply received.
on_reserve_replyReserve reply received.
on_heartbeat_requestHeartbeat request from train node.