ProtoCore v0.0.1
Deterministic, zero-heap network stack for embedded targets
Loading...
Searching...
No Matches
safety_scl.h
Go to the documentation of this file.
1// Copyright (C) 2026 Douglas Quigg (dstroy0) <dquigg123@gmail.com>
2// SPDX-License-Identifier: AGPL-3.0-or-later
3
4/**
5 * @file safety_scl.h
6 * @brief IEC 61784-3 black-channel Safety Communication Layer - the shared primitives
7 * (PC_ENABLE_SAFETY_SCL).
8 *
9 * The functional-safety profiles (PROFIsafe / IEC 61784-3-3, CIP Safety / -3-2, FSoE / -3-12,
10 * IO-Link Safety) all work the same way: they treat the underlying fieldbus as an untrusted "black
11 * channel" and layer their own end-to-end checks on top, so the transport may corrupt, lose,
12 * duplicate, delay or reorder frames without defeating the safety function. Three mechanisms do that
13 * work, and they are common to every profile:
14 *
15 * 1. a **CRC signature** over the safety payload, sized so the residual error rate meets SIL 3,
16 * 2. a **monitoring / consecutive counter**, which turns lost, repeated, inserted and reordered
17 * frames into a detectable mismatch, and
18 * 3. a **receive watchdog**, so a silent channel is itself a fault rather than a stale-but-happy
19 * reading.
20 *
21 * This module lands (2) and (3) plus the fail-safe state machine that combines all three, so each
22 * profile's codec composes them instead of reimplementing them.
23 *
24 * **What this module deliberately does not do: compute the CRC.** Every profile defines its own
25 * polynomial, width, seed, and the order in which payload / counter / connection identifier are fed
26 * into it, and those constants live in paid standards. Encoding a guess here would produce something
27 * that looks authoritative and silently fails to detect the very corruptions it exists to catch -
28 * the worst possible outcome for a safety layer. So the caller computes its profile's CRC and passes
29 * the verdict in as @p signature_ok. What this module owns is the *consequence* of that verdict,
30 * which genuinely is profile-independent.
31 *
32 * **Fail-safe latches.** Once any check fails the connection enters @ref SclState::FAILSAFE and stays
33 * there until an explicit @ref pc_scl_reset. A safety layer must never silently reheal: recovering
34 * on its own would let an intermittent fault present as a working connection, which is precisely the
35 * failure a SIL rating is meant to exclude. Re-establishing is an operator/application decision.
36 *
37 * Pure, with an explicit @p now (the `services/hotswap` pattern - it decides, the binding acts), so
38 * the whole machine is host-testable against a synthetic clock. Every elapsed-time test is an
39 * unsigned difference, so it is wrap-safe across a `millis()` rollover. No heap, no stdlib.
40 *
41 * @author Douglas Quigg (dstroy0)
42 * @date 2026
43 */
44
45#ifndef PROTOCORE_SAFETY_SCL_H
46#define PROTOCORE_SAFETY_SCL_H
47
48#include "protocore_config.h"
49
50#if PC_ENABLE_SAFETY_SCL
51
52#include <stdint.h>
53
54/** @brief Where a safety connection stands. */
55enum class SclState : uint8_t
56{
57 INIT = 0, ///< initialized, no valid frame accepted yet.
58 RUNNING = 1, ///< at least one frame accepted and no fault since.
59 FAILSAFE = 2, ///< a check failed; latched until pc_scl_reset().
60};
61
62/** @brief Why a connection went fail-safe. */
63enum class SclFault : uint8_t
64{
65 PC_NONE = 0, ///< no fault.
66 SIGNATURE = 1, ///< the caller's CRC check rejected the frame (corruption).
67 COUNTER = 2, ///< the monitoring counter was not the expected next value.
68 TIMEOUT = 3, ///< no valid frame arrived within the watchdog.
69};
70
71/**
72 * @brief One safety connection's receive state.
73 *
74 * @ref counter_mod is the monitoring counter's wrap modulus - profiles use narrow counters (an
75 * 8-bit consecutive number is common), so the expected value must wrap the same way the sender's
76 * does or the two desynchronize after the first wrap. 0 means the full 32-bit range.
77 */
78struct SclConn
79{
80 SclState state; ///< current state.
81 SclFault fault; ///< why it went fail-safe (PC_NONE unless FAILSAFE).
82 uint32_t expected; ///< monitoring counter value the next frame must carry.
83 uint32_t counter_mod; ///< counter wrap modulus (0 = full 32-bit range).
84 uint32_t watchdog_ms; ///< maximum gap between accepted frames (0 disables the watchdog).
85 uint32_t last_ok_ms; ///< when the last frame was accepted.
86 uint32_t accepted; ///< frames accepted since init.
87 uint32_t rejected; ///< frames rejected since init.
88};
89
90/**
91 * @brief Initialize a connection to @ref SclState::INIT at @p now.
92 *
93 * @param first_counter the monitoring counter value the first frame is required to carry.
94 * @param counter_mod counter wrap modulus; 0 for the full 32-bit range.
95 * @param watchdog_ms maximum gap between accepted frames; 0 disables the watchdog.
96 */
97void pc_scl_init(SclConn *c, uint32_t first_counter, uint32_t counter_mod, uint32_t watchdog_ms, uint32_t now);
98
99/**
100 * @brief Offer one received frame to the connection.
101 *
102 * @param signature_ok the profile's CRC verdict for this frame - see the file comment on why this
103 * module does not compute it.
104 * @param counter the monitoring counter carried by the frame.
105 *
106 * A frame is accepted only when the signature is good AND the counter is exactly the expected next
107 * value. Every way the black channel can misbehave shows up here: a lost frame or an inserted one
108 * makes the counter run ahead, a duplicate makes it repeat, and a reordered pair makes it go
109 * backwards - all of which are simply "not the expected value", which is what makes one comparison
110 * sufficient. Any failure latches @ref SclState::FAILSAFE.
111 *
112 * Offering a frame to an already-fail-safe connection is refused without changing the recorded
113 * fault: the first fault is the diagnostically interesting one.
114 *
115 * @return true if the frame was accepted (its safety payload may be used).
116 */
117bool pc_scl_on_frame(SclConn *c, bool signature_ok, uint32_t counter, uint32_t now);
118
119/**
120 * @brief Check the receive watchdog at @p now. Call it every cycle, including cycles with no frame -
121 * a silent channel is exactly what this detects.
122 *
123 * The watchdog only runs once a connection is @ref SclState::RUNNING: a connection that has not yet
124 * received its first frame is not yet timing out, it is still starting up.
125 *
126 * @return true if the connection is still usable (not fail-safe).
127 */
128bool pc_scl_poll(SclConn *c, uint32_t now);
129
130/**
131 * @brief Re-establish a fail-safe connection, arming it for @p first_counter at @p now.
132 *
133 * Deliberately explicit: see the file comment on why the layer never rehabilitates itself.
134 * Counters are preserved so the accepted/rejected tallies span the whole session.
135 */
136void pc_scl_reset(SclConn *c, uint32_t first_counter, uint32_t now);
137
138/** @brief True while the connection may be used (not fail-safe). */
139bool pc_scl_ok(const SclConn *c);
140
141/** @brief The connection's state. */
142SclState pc_scl_state(const SclConn *c);
143
144/** @brief Why the connection went fail-safe (@ref SclFault::PC_NONE unless it did). */
145SclFault pc_scl_fault(const SclConn *c);
146
147/**
148 * @brief Advance a *sender's* monitoring counter, honouring the same wrap modulus.
149 *
150 * Provided so a sender and its peer receiver cannot disagree about wrapping - the commonest way a
151 * hand-rolled consecutive number desynchronizes after its first wrap.
152 *
153 * @return the next counter value to put on the wire.
154 */
155uint32_t pc_scl_next_counter(uint32_t counter, uint32_t counter_mod);
156
157#endif // PC_ENABLE_SAFETY_SCL
158#endif // PROTOCORE_SAFETY_SCL_H
User-facing configuration for ProtoCore.