/* ========================================================================
* Copyright (c) 2005-2025 The OPC Foundation, Inc. All rights reserved.
*
* OPC Foundation MIT License 1.00
*
* Permission is hereby granted, free of charge, to any person
* obtaining a copy of this software and associated documentation
* files (the "Software"), to deal in the Software without
* restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the
* Software is furnished to do so, subject to the following
* conditions:
*
* The above copyright notice and this permission notice shall be
* included in all copies or substantial portions of the Software.
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
* EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
* OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
* NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
* HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
* WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
* FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
* OTHER DEALINGS IN THE SOFTWARE.
*
* The complete license agreement can be found here:
* http://opcfoundation.org/License/MIT/1.00/
* ======================================================================*/
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Threading;
namespace Opc.Ua.Server.StateMachines
{
///
/// Unified fluent builder for Part 16 finite state machines.
/// Combines two complementary modes in a single API:
///
///
///
///
/// -
/// Definition mode —
/// constructs a new
/// in the address space
/// and exposes /
/// / for declarative table construction.
///
/// -
/// Lifecycle mode —
/// adopts an
/// already-Created state machine (stack-shipped, generator-emitted,
/// or vendor) and wires
/// / /
/// / /
/// /
/// behavior on top of the existing tables.
///
///
///
/// Both modes share the same lifecycle surface. The first call to
/// any lifecycle method (or the first access to
/// ) freezes the underlying definition
/// holder — subsequent / etc. calls throw.
///
///
/// The builder is intentionally not .
/// Lifecycle hooks attach to the state machine's own
/// OnBeforeTransition / OnAfterTransition delegate
/// fields; the dispatcher (with its timers and per-state handler
/// tables) is kept alive by those delegates and garbage-collected
/// when the state machine itself becomes unreachable.
///
///
public sealed class StateMachineBuilder
where TState : FiniteStateMachineState
{
private readonly TState m_stateMachine;
private readonly ISystemContext m_context;
private readonly MutableStateMachineDefinition? m_definition;
private readonly StateMachineDispatcher m_dispatcher;
internal StateMachineBuilder(
TState stateMachine,
ISystemContext context,
MutableStateMachineDefinition? definition,
NodeId deferredCreateNodeId = default,
QualifiedName deferredCreateBrowseName = default)
{
m_stateMachine = stateMachine
?? throw new ArgumentNullException(nameof(stateMachine));
m_context = context
?? throw new ArgumentNullException(nameof(context));
m_definition = definition;
m_deferredCreateNodeId = deferredCreateNodeId;
m_deferredCreateBrowseName = deferredCreateBrowseName;
TimeProvider timeProvider =
((context as ServerSystemContext)?.Server as ITimeProviderProvider)?.TimeProvider
?? TimeProvider.System;
m_dispatcher = new StateMachineDispatcher(stateMachine, context, timeProvider);
}
private readonly NodeId m_deferredCreateNodeId;
private readonly QualifiedName m_deferredCreateBrowseName;
///
/// The underlying state machine. Reading this property freezes
/// the definition holder (if any).
///
public TState StateMachine
{
get
{
FreezeDefinition();
return m_stateMachine;
}
}
///
/// Adds a state to the machine. Definition-mode only — the
/// underlying state machine must be a
/// .
///
/// The numeric state id.
/// The browse name.
/// Whether this is the initial state.
///
/// is null/empty or
/// is already declared.
///
/// The builder is in lifecycle mode (no definition), the
/// definition has been frozen, or another state was already
/// marked initial.
public StateMachineBuilder AddState(
uint id,
string browseName,
bool isInitial = false)
{
MutableStateMachineDefinition def = RequireDefinition();
def.EnsureNotFrozen();
if (string.IsNullOrEmpty(browseName))
{
throw new ArgumentException("Browse name must not be empty.",
nameof(browseName));
}
foreach (StateMachineStateDefinition existing in def.States)
{
if (existing.Id == id)
{
throw new ArgumentException(
$"State id {id} is already declared.", nameof(id));
}
}
def.States.Add(new StateMachineStateDefinition(id, browseName, isInitial));
if (isInitial)
{
if (def.InitialStateId.HasValue && def.InitialStateId.Value != id)
{
throw new InvalidOperationException(
"Only one state may be marked as the initial state.");
}
def.InitialStateId = id;
}
def.Version++;
return this;
}
///
/// Adds a transition between two states. Definition-mode only.
///
/// The numeric transition id.
/// The browse name.
/// The from-state id.
/// The to-state id.
/// Whether the transition fires a
/// TransitionEventType.
///
/// is null/empty or
/// is already declared.
public StateMachineBuilder AddTransition(
uint id,
string browseName,
uint from,
uint to,
bool hasEffect = true)
{
MutableStateMachineDefinition def = RequireDefinition();
def.EnsureNotFrozen();
if (string.IsNullOrEmpty(browseName))
{
throw new ArgumentException("Browse name must not be empty.",
nameof(browseName));
}
foreach (StateMachineTransitionDefinition existing in def.Transitions)
{
if (existing.Id == id)
{
throw new ArgumentException(
$"Transition id {id} is already declared.", nameof(id));
}
}
def.Transitions.Add(new StateMachineTransitionDefinition(
id, browseName, from, to, hasEffect));
def.Version++;
return this;
}
///
/// Adds a cause-to-transition mapping. When the method with
/// the given cause id is invoked while the machine is in the
/// given from-state, the named transition fires.
/// Definition-mode only.
///
public StateMachineBuilder OnCause(
uint causeId,
uint from,
uint transition)
{
MutableStateMachineDefinition def = RequireDefinition();
def.EnsureNotFrozen();
def.CauseMappings.Add(new StateMachineCauseMapping(
causeId, from, transition));
def.Version++;
return this;
}
///
/// Designates the namespace URI that qualifies the state and
/// transition numeric NodeIds. Defaults to the standard UA
/// namespace. Definition-mode only.
///
///
/// is null or empty.
public StateMachineBuilder UseElementNamespace(string namespaceUri)
{
MutableStateMachineDefinition def = RequireDefinition();
def.EnsureNotFrozen();
if (string.IsNullOrEmpty(namespaceUri))
{
throw new ArgumentException("Namespace URI must not be empty.",
nameof(namespaceUri));
}
def.ElementNamespaceUri = namespaceUri;
def.Version++;
return this;
}
///
/// Sets the initial state — applied immediately via
/// . Does not
/// invoke lifecycle handlers (initial state assignment is not
/// a transition).
///
public StateMachineBuilder WithInitialState(uint stateId)
{
FreezeDefinition();
ValidateDefinitionStateExists(stateId);
m_stateMachine.SetState(m_context, stateId);
return this;
}
///
/// Registers a handler invoked when the state machine enters
/// the given state. Order within a single transition is
/// OnExitState(from) → OnTransition(from, to) → OnEnterState(to).
///
public StateMachineBuilder OnEnterState(
uint stateId,
Action handler)
{
if (handler is null)
{
throw new ArgumentNullException(nameof(handler));
}
FreezeDefinition();
m_dispatcher.AddEnterStateHandler(stateId, handler);
return this;
}
///
/// Async overload of . The handler
/// is invoked fire-and-forget on the thread pool from the sync
/// transition path, so the handler runs on a fully-async path
/// (no GetAwaiter().GetResult() / Wait() /
/// Result) and never blocks the transition. Exceptions
/// are captured and logged.
///
#pragma warning disable RCS1047 // 'Async' suffix marks the registered callback as async, not this registration method.
public StateMachineBuilder OnEnterStateAsync(
uint stateId,
Func handler)
#pragma warning restore RCS1047
{
if (handler is null)
{
throw new ArgumentNullException(nameof(handler));
}
FreezeDefinition();
m_dispatcher.AddEnterStateHandlerAsync(stateId, handler);
return this;
}
///
/// Registers a handler invoked when the state machine leaves
/// the given state.
///
public StateMachineBuilder OnExitState(
uint stateId,
Action handler)
{
if (handler is null)
{
throw new ArgumentNullException(nameof(handler));
}
FreezeDefinition();
m_dispatcher.AddExitStateHandler(stateId, handler);
return this;
}
///
/// Async overload of . See
/// for invocation semantics.
///
#pragma warning disable RCS1047 // 'Async' suffix marks the registered callback as async, not this registration method.
public StateMachineBuilder OnExitStateAsync(
uint stateId,
Func handler)
#pragma warning restore RCS1047
{
if (handler is null)
{
throw new ArgumentNullException(nameof(handler));
}
FreezeDefinition();
m_dispatcher.AddExitStateHandlerAsync(stateId, handler);
return this;
}
///
/// Registers a generic transition observer invoked after every
/// completed transition. Receives the resolved from and
/// to state ids (extracted from LastState /
/// CurrentState).
///
public StateMachineBuilder OnTransition(
Action handler)
{
if (handler is null)
{
throw new ArgumentNullException(nameof(handler));
}
FreezeDefinition();
m_dispatcher.AddTransitionObserver(handler);
return this;
}
///
/// Async overload of . See
/// for invocation semantics.
///
#pragma warning disable RCS1047 // 'Async' suffix marks the registered callback as async, not this registration method.
public StateMachineBuilder OnTransitionAsync(
Func handler)
#pragma warning restore RCS1047
{
if (handler is null)
{
throw new ArgumentNullException(nameof(handler));
}
FreezeDefinition();
m_dispatcher.AddTransitionObserverAsync(handler);
return this;
}
///
/// Registers a pre-transition guard. Returning a non-Good
/// cancels the transition. The
/// builder guards run BEFORE any pre-existing
/// OnBeforeTransition delegate the state machine had
/// when the builder adopted it.
///
public StateMachineBuilder OnBeforeTransition(
Func guard)
{
if (guard is null)
{
throw new ArgumentNullException(nameof(guard));
}
FreezeDefinition();
m_dispatcher.AddBeforeTransitionGuard(guard);
return this;
}
///
/// Adds a transition-scoped guard. The predicate fires only
/// when the inbound transition matches
/// ; returning false
/// vetoes the transition with
/// . Guards
/// registered via the When* family compose with
/// in registration order —
/// the first failing guard wins.
///
public StateMachineBuilder WhenTransition(
uint transitionId,
Func predicate)
{
return WhenTransition(transitionId, predicate, StatusCodes.BadUserAccessDenied);
}
///
/// Overload of
/// that lets the caller customize the deny status returned
/// when the predicate evaluates to false.
///
public StateMachineBuilder WhenTransition(
uint transitionId,
Func predicate,
ServiceResult denyStatus)
{
if (predicate is null)
{
throw new ArgumentNullException(nameof(predicate));
}
FreezeDefinition();
m_dispatcher.AddBeforeTransitionGuard((ctx, sm, tid, cid) =>
{
if (tid != transitionId)
{
return ServiceResult.Good;
}
return predicate(ctx, sm) ? ServiceResult.Good : denyStatus;
});
return this;
}
///
/// Adds a cause-scoped guard. The predicate fires only when
/// the inbound cause id matches .
/// Useful for permission checks on specific method-driven
/// transitions (e.g. Acknowledge, Suspend).
///
public StateMachineBuilder WhenCause(
uint causeId,
Func predicate)
{
return WhenCause(causeId, predicate, StatusCodes.BadUserAccessDenied);
}
///
/// Overload of
/// with a caller-supplied deny status.
///
public StateMachineBuilder WhenCause(
uint causeId,
Func predicate,
ServiceResult denyStatus)
{
if (predicate is null)
{
throw new ArgumentNullException(nameof(predicate));
}
FreezeDefinition();
m_dispatcher.AddBeforeTransitionGuard((ctx, sm, tid, cid) =>
{
if (cid != causeId)
{
return ServiceResult.Good;
}
return predicate(ctx, sm) ? ServiceResult.Good : denyStatus;
});
return this;
}
///
/// Adds an enter-state-scoped guard. The predicate fires only
/// when the inbound transition would put the machine into
/// . Definition-mode only — the
/// builder uses its own transition table to resolve the
/// to-state from the transition id.
///
///
/// The builder is in lifecycle mode (no definition).
///
public StateMachineBuilder WhenEnter(
uint toStateId,
Func predicate)
{
return WhenEnter(toStateId, predicate, StatusCodes.BadUserAccessDenied);
}
///
/// Overload of
/// with a caller-supplied deny status.
///
public StateMachineBuilder WhenEnter(
uint toStateId,
Func predicate,
ServiceResult denyStatus)
{
if (predicate is null)
{
throw new ArgumentNullException(nameof(predicate));
}
if (m_definition == null)
{
throw new InvalidOperationException(
"WhenEnter is only available in definition mode. "
+ "Use WhenTransition with the relevant transition id "
+ "in lifecycle mode.");
}
FreezeDefinition();
// Snapshot the matching transitions at registration time;
// the definition is frozen so this list is stable.
HashSet matchingTransitions = [];
foreach (StateMachineTransitionDefinition t in m_definition.Transitions)
{
if (t.ToStateId == toStateId)
{
matchingTransitions.Add(t.Id);
}
}
m_dispatcher.AddBeforeTransitionGuard((ctx, sm, tid, cid) =>
{
if (!matchingTransitions.Contains(tid))
{
return ServiceResult.Good;
}
return predicate(ctx, sm) ? ServiceResult.Good : denyStatus;
});
return this;
}
///
/// Adds an exit-state-scoped guard. The predicate fires only
/// when the inbound transition would leave
/// . Works in both definition
/// and lifecycle modes — the dispatcher captures the current
/// state in DispatchBefore and matches against
/// .
///
public StateMachineBuilder WhenExit(
uint fromStateId,
Func predicate)
{
return WhenExit(fromStateId, predicate, StatusCodes.BadUserAccessDenied);
}
///
/// Overload of
/// with a caller-supplied deny status.
///
public StateMachineBuilder WhenExit(
uint fromStateId,
Func predicate,
ServiceResult denyStatus)
{
if (predicate is null)
{
throw new ArgumentNullException(nameof(predicate));
}
FreezeDefinition();
m_dispatcher.AddBeforeTransitionGuard((ctx, sm, tid, cid) =>
{
uint currentFrom = ExtractCurrentStateId(sm);
if (currentFrom != fromStateId)
{
return ServiceResult.Good;
}
return predicate(ctx, sm) ? ServiceResult.Good : denyStatus;
});
return this;
}
private static uint ExtractCurrentStateId(TState sm)
{
NodeId? id = sm.CurrentState?.Id?.Value;
if (id is null || id.Value.IsNull)
{
return 0;
}
return id.Value.TryGetValue(out uint stateId) ? stateId : 0;
}
///
/// Attaches a sub-state-machine to the parent state identified
/// by . The sub-SM is added
/// as a HasSubStateMachine-referenced child of the
/// parent FSM, configured through the nested
/// builder, and managed by the
/// dispatcher lifecycle:
///
///
///
/// -
/// When the parent enters ,
/// the child sub-SM's
/// is set to false and (unless
/// is true) the
/// child is reset to its declared initial state.
///
/// -
/// When the parent exits ,
/// the child is suspended — subsequent DoTransition /
/// DoCause calls on the child return
/// until the parent
/// re-enters the attached state.
///
///
///
/// Definition-mode only: the parent must be a
/// . In lifecycle
/// mode (when adopting a stack-shipped or vendor FSM) the
/// sub-SM is already part of the type definition; observe it
/// through the client-side sub-SM accessors instead.
///
///
/// The parent state whose entry
/// activates the sub-SM.
/// The sub-SM's browse name (becomes
/// the child node's BrowseName).
/// Nested builder action that defines
/// the sub-SM's states, transitions, and behavior.
/// When true, the
/// sub-SM retains its last state across parent re-entries;
/// otherwise it resets to its declared initial state.
///
/// Definition-mode only — the builder must own a
/// .
///
public StateMachineBuilder WithSubStateMachine(
uint parentStateId,
QualifiedName browseName,
Action> configure,
bool preserveOnReentry = false)
{
if (configure is null)
{
throw new ArgumentNullException(nameof(configure));
}
if (browseName.IsNull)
{
throw new ArgumentException(
"Browse name must not be null.", nameof(browseName));
}
if (m_definition == null)
{
throw new InvalidOperationException(
"WithSubStateMachine is only available in definition "
+ "mode (use StateMachineBuilder.Create). Lifecycle-mode "
+ "FSMs already declare their sub-state-machines as part "
+ "of the type definition.");
}
FreezeDefinition();
// Configure the sub-SM through a nested builder. The
// sub-SM's NodeId is derived from the parent's NodeId so
// it lives under the parent in the address space.
var subHolder = new MutableStateMachineDefinition();
var child =
FluentFiniteStateMachineState.CreateWithHolder(m_stateMachine, subHolder);
NodeId childNodeId = m_stateMachine.NodeId.IsNull
? new NodeId(Guid.NewGuid())
: ComposeChildNodeId(m_stateMachine.NodeId, browseName);
child.Create(
m_context,
childNodeId,
browseName,
new LocalizedText(browseName.Name ?? string.Empty),
true);
var childBuilder = new StateMachineBuilder(
child, m_context, subHolder);
configure(childBuilder);
// Read StateMachine to freeze the child's definition and
// ensure it is fully constructed.
FluentFiniteStateMachineState materializedChild = childBuilder.StateMachine;
// Set the reference type BEFORE adding to the parent so
// AddChild does not default it to HasComponent. Then add.
materializedChild.ReferenceTypeId = ReferenceTypeIds.HasSubStateMachine;
m_stateMachine.AddChild(materializedChild);
// Initial state of the sub-SM is "suspended" unless the
// parent already starts in the attached state.
uint initialChildStateId = subHolder.InitialStateId ?? 0;
bool parentInAttachedState =
ExtractCurrentStateId(m_stateMachine) == parentStateId;
materializedChild.IsSuspended = !parentInAttachedState;
if (parentInAttachedState && initialChildStateId != 0)
{
// Set the child to its initial state immediately —
// the parent's OnEnterState handler isn't fired on
// WithInitialState (it goes through SetState, not
// DoTransition), so we must seed the child here.
materializedChild.SetState(m_context, initialChildStateId);
}
// Wire the lifecycle hooks on the parent.
m_dispatcher.AddEnterStateHandler(parentStateId, (ctx, parent) =>
{
if (!preserveOnReentry && initialChildStateId != 0)
{
materializedChild.IsSuspended = false;
materializedChild.SetState(ctx, initialChildStateId);
}
else
{
materializedChild.IsSuspended = false;
}
});
m_dispatcher.AddExitStateHandler(parentStateId, (ctx, parent) => materializedChild.IsSuspended = true);
return this;
}
private static NodeId ComposeChildNodeId(NodeId parentNodeId, QualifiedName browseName)
{
// Derive a deterministic child NodeId from the parent
// and the child's browse name so multiple
// WithSubStateMachine calls produce stable, distinct ids.
string suffix = browseName.Name ?? "Child";
if (parentNodeId.TryGetValue(out string parentStr))
{
return new NodeId(parentStr + "_" + suffix, parentNodeId.NamespaceIndex);
}
return new NodeId(parentNodeId + "_" + suffix, parentNodeId.NamespaceIndex);
}
///
/// Binds an inbound method call on the given method node to
/// the cause-processing pipeline. The cause id is derived from
/// 's numeric identifier (the
/// OPC UA convention). The cause-to-transition mapping must
/// already exist on the state machine — either via
/// (definition mode) or hardcoded in a
/// stack/vendor subclass.
///
/// The method node whose
/// OnCallMethod2 handler will be installed.
///
/// is null, has a non-numeric
/// identifier, or could not be resolved to a child method
/// of the state machine.
///
public StateMachineBuilder WithCause(NodeId methodNodeId)
{
FreezeDefinition();
if (methodNodeId.IsNull)
{
throw new ArgumentException(
"Method node id must not be null.",
nameof(methodNodeId));
}
if (!methodNodeId.TryGetValue(out uint causeId))
{
throw new ArgumentException(
"Method node id must have a numeric identifier; " +
$"got '{methodNodeId.IdentifierAsString}' " +
$"(type {methodNodeId.IdType}).",
nameof(methodNodeId));
}
MethodState? method = FindMethodInTree(m_stateMachine, methodNodeId) ??
throw new ArgumentException(
$"Method '{methodNodeId}' not found among the state " +
"machine's children.",
nameof(methodNodeId));
m_dispatcher.InstallCauseMethod(method, causeId);
return this;
}
///
/// Registers an automatic transition that fires after the
/// machine has been in for at
/// least . The timer is armed on
/// entry into and cancelled on
/// exit; multiple entries re-arm a fresh timer.
///
/// The state whose entry arms the
/// timer.
/// The duration to wait before firing
/// the transition.
/// The transition to fire when the
/// timer expires — driven via
/// .
/// The cause id reported in the audit
/// event (0 if no human-meaningful cause).
///
/// is zero or negative.
public StateMachineBuilder WithTimedTransition(
uint fromStateId,
TimeSpan timeout,
uint transitionId,
uint causeId = 0)
{
if (timeout <= TimeSpan.Zero)
{
throw new ArgumentOutOfRangeException(nameof(timeout),
"Timeout must be positive.");
}
FreezeDefinition();
ValidateTimedTransition(fromStateId, transitionId);
m_dispatcher.AddTimedTransition(
fromStateId, timeout, transitionId, causeId);
return this;
}
private void ValidateTimedTransition(uint fromStateId, uint transitionId)
{
// Validate only when the builder owns the definition. For
// lifecycle-mode (For()) we can't reach the FSM's protected
// tables — the caller is responsible.
if (m_definition == null)
{
return;
}
bool fromOk = false;
foreach (StateMachineStateDefinition s in m_definition.States)
{
if (s.Id == fromStateId)
{
fromOk = true;
break;
}
}
if (!fromOk)
{
throw new InvalidOperationException(
"Timed transition references unknown from-state " +
$"{fromStateId}.");
}
bool transitionOk = false;
foreach (StateMachineTransitionDefinition t in m_definition.Transitions)
{
if (t.Id == transitionId)
{
if (t.FromStateId != fromStateId)
{
throw new InvalidOperationException(
$"Timed transition {transitionId} declares " +
$"from-state {t.FromStateId} but was " +
$"registered with fromStateId={fromStateId}.");
}
transitionOk = true;
break;
}
}
if (!transitionOk)
{
throw new InvalidOperationException(
"Timed transition references unknown transition " +
$"{transitionId}.");
}
}
///
/// Escape hatch — invokes with the
/// underlying state machine. Useful for setting properties or
/// invoking generated-type setters that the builder does not
/// surface directly.
///
public StateMachineBuilder ConfigureStateMachine(
Action configure)
{
if (configure is null)
{
throw new ArgumentNullException(nameof(configure));
}
FreezeDefinition();
configure(m_stateMachine);
return this;
}
private MutableStateMachineDefinition RequireDefinition()
{
if (m_definition == null)
{
throw new InvalidOperationException(
"Definition-mode methods (AddState / AddTransition / " +
"OnCause / UseElementNamespace) are only available " +
"when the builder owns the state-machine definition. " +
"Use StateMachineBuilder.Create(...) to create a " +
"FluentFiniteStateMachineState, or stick to lifecycle " +
"methods (OnEnterState / WithCause / ...) when " +
"wrapping an existing state machine via " +
"StateMachineBuilder.For(...).");
}
return m_definition;
}
private void FreezeDefinition()
{
if (m_definition != null && !m_definition.Frozen)
{
ValidateDefinition(m_definition);
m_definition.Frozen = true;
m_definition.Version++;
// For definition-mode builders the FSM has not yet been
// added to the address space — we defer
// NodeState.Create() until freeze so callers can use
// UseElementNamespace() to override the element
// namespace URI before OnAfterCreate caches the
// namespace index.
if (!m_deferredCreateNodeId.IsNull && !m_deferredCreateBrowseName.IsNull)
{
m_stateMachine.Create(
m_context,
m_deferredCreateNodeId,
m_deferredCreateBrowseName,
new LocalizedText(m_deferredCreateBrowseName.Name),
true);
}
}
}
private void ValidateDefinitionStateExists(uint stateId)
{
if (m_definition == null)
{
return;
}
foreach (StateMachineStateDefinition s in m_definition.States)
{
if (s.Id == stateId)
{
return;
}
}
throw new InvalidOperationException(
$"Initial state {stateId} is not declared. Call AddState " +
"to declare it before WithInitialState.");
}
private static void ValidateDefinition(MutableStateMachineDefinition def)
{
if (def.States.Count == 0)
{
throw new InvalidOperationException(
"At least one state must be declared before the " +
"definition is frozen.");
}
var stateIds = new HashSet();
foreach (StateMachineStateDefinition s in def.States)
{
stateIds.Add(s.Id);
}
foreach (StateMachineTransitionDefinition t in def.Transitions)
{
if (!stateIds.Contains(t.FromStateId))
{
throw new InvalidOperationException(
$"Transition '{t.BrowseName}' references unknown " +
$"from-state {t.FromStateId}.");
}
if (!stateIds.Contains(t.ToStateId))
{
throw new InvalidOperationException(
$"Transition '{t.BrowseName}' references unknown " +
$"to-state {t.ToStateId}.");
}
}
var transitionIds = new HashSet();
foreach (StateMachineTransitionDefinition t in def.Transitions)
{
transitionIds.Add(t.Id);
}
foreach (StateMachineCauseMapping c in def.CauseMappings)
{
if (!stateIds.Contains(c.FromStateId))
{
throw new InvalidOperationException(
"Cause mapping references unknown from-state " +
$"{c.FromStateId}.");
}
if (!transitionIds.Contains(c.TransitionId))
{
throw new InvalidOperationException(
"Cause mapping references unknown transition " +
$"{c.TransitionId}.");
}
}
// Detect duplicate cause mappings — only the first match
// would ever fire, so silently shadowed mappings are an
// authoring bug.
var seenCauseKeys = new HashSet<(uint, uint)>();
foreach (StateMachineCauseMapping c in def.CauseMappings)
{
if (!seenCauseKeys.Add((c.CauseId, c.FromStateId)))
{
throw new InvalidOperationException(
"Duplicate cause mapping for causeId=" +
$"{c.CauseId}, fromState={c.FromStateId} — " +
"subsequent mappings would be silently " +
"shadowed by the first.");
}
}
}
private static MethodState? FindMethodInTree(
NodeState root, NodeId methodNodeId)
{
var children = new List();
root.GetChildren(null!, children);
foreach (BaseInstanceState child in children)
{
if (child is MethodState method &&
method.NodeId == methodNodeId)
{
return method;
}
MethodState? nested = FindMethodInTree(child, methodNodeId);
if (nested != null)
{
return nested;
}
}
return null;
}
}
///
/// Static facade for .
///
public static class StateMachineBuilder
{
///
/// Creates a new
/// in the address space and returns a builder ready for
/// definition-mode chaining
/// ( /
/// /
/// ).
///
/// The parent node (may be null
/// for stand-alone machines).
/// The system context used for the
/// underlying call.
/// The state machine's NodeId.
/// The state machine's browse
/// name.
///
/// or
/// is null.
public static StateMachineBuilder Create(
NodeState? parent,
ISystemContext context,
NodeId nodeId,
QualifiedName browseName)
{
if (context is null)
{
throw new ArgumentNullException(nameof(context));
}
if (nodeId.IsNull)
{
throw new ArgumentException(
"NodeId must not be null.", nameof(nodeId));
}
if (browseName.IsNull)
{
throw new ArgumentException(
"Browse name must not be null.", nameof(browseName));
}
var holder = new MutableStateMachineDefinition();
var sm =
FluentFiniteStateMachineState.CreateWithHolder(parent!, holder);
// Defer sm.Create() until FreezeDefinition() so that
// UseElementNamespace() called between Create() and the
// first lifecycle method takes effect — OnAfterCreate
// caches ElementNamespaceIndex from ElementNamespaceUri.
return new StateMachineBuilder(
sm, context, holder,
deferredCreateNodeId: nodeId,
deferredCreateBrowseName: browseName);
}
///
/// Adopts an existing, already-Created
/// instance and returns a
/// builder ready for lifecycle-mode chaining. Definition
/// methods (
/// etc.) throw in this
/// mode.
///
/// The concrete
/// subclass of the
/// existing state machine.
/// The pre-existing state machine
/// (e.g. a ShelvedStateMachineState or a
/// generator-emitted vendor type).
/// The system context used to drive
/// auto-transitions and method dispatch.
public static StateMachineBuilder For(
TState stateMachine,
ISystemContext context)
where TState : FiniteStateMachineState
{
if (stateMachine is null)
{
throw new ArgumentNullException(nameof(stateMachine));
}
if (context is null)
{
throw new ArgumentNullException(nameof(context));
}
return new StateMachineBuilder(stateMachine, context, null);
}
}
///
/// Internal dispatcher that wires builder-collected handlers onto
/// the state machine's OnBefore/AfterTransition delegates,
/// resolves the from/to numeric state ids from
/// LastState/CurrentState, and owns the per-state
/// timed-transition registry.
///
/// The concrete
/// subclass the dispatcher
/// wraps.
internal sealed class StateMachineDispatcher
where TState : FiniteStateMachineState
{
private readonly TState m_stateMachine;
private readonly ISystemContext m_context;
private readonly TimeProvider m_timeProvider;
private readonly Dictionary>> m_enterHandlers = [];
private readonly Dictionary>> m_exitHandlers = [];
private readonly List> m_transitionObservers = [];
private readonly List> m_guards = [];
private readonly Dictionary m_timedTransitions = [];
///
/// Async observer counterparts. Scheduled fire-and-forget from
/// the sync transition path (which itself remains sync since
/// FiniteStateMachineState.DoTransition is sync). Each invocation
/// runs on the thread pool with ConfigureAwait(false), so no
/// sync-over-async wait occurs anywhere; exceptions are
/// captured and logged via Debug.WriteLine in line with the
/// existing SafeInvoke pattern.
///
private readonly Dictionary>>
m_enterHandlersAsync = [];
private readonly Dictionary>>
m_exitHandlersAsync = [];
private readonly
List>
m_transitionObserversAsync = [];
private readonly StateMachineTransitionHandler? m_originalBefore;
private readonly StateMachineTransitionHandler? m_originalAfter;
private bool m_installed;
///
/// Thread-keyed pending-from-state so concurrent transitions
/// do not clobber each other's stash. OPC UA service requests
/// can hit a node manager concurrently; each DoTransition runs
/// synchronously on one thread (DispatchBefore -> state update
/// -> DispatchAfter all on the same thread), so a thread-id
/// keyed slot is sufficient.
///
private readonly System.Collections.Concurrent.ConcurrentDictionary
m_pendingFromByThread = new();
public StateMachineDispatcher(TState stateMachine, ISystemContext context)
: this(stateMachine, context, TimeProvider.System)
{
}
public StateMachineDispatcher(
TState stateMachine,
ISystemContext context,
TimeProvider timeProvider)
{
m_stateMachine = stateMachine;
m_context = context;
m_timeProvider = timeProvider ?? TimeProvider.System;
m_originalBefore = stateMachine.OnBeforeTransition;
m_originalAfter = stateMachine.OnAfterTransition;
}
public void AddEnterStateHandler(
uint stateId, Action handler)
{
if (!m_enterHandlers.TryGetValue(stateId, out List>? list))
{
list = [];
m_enterHandlers[stateId] = list;
}
list.Add(handler);
EnsureInstalled();
}
public void AddExitStateHandler(
uint stateId, Action handler)
{
if (!m_exitHandlers.TryGetValue(stateId, out List>? list))
{
list = [];
m_exitHandlers[stateId] = list;
}
list.Add(handler);
EnsureInstalled();
}
public void AddTransitionObserver(
Action handler)
{
m_transitionObservers.Add(handler);
EnsureInstalled();
}
#pragma warning disable RCS1047 // 'Async' suffix marks the registered callback as async, not this registration method.
public void AddEnterStateHandlerAsync(
uint stateId,
Func handler)
#pragma warning restore RCS1047
{
if (!m_enterHandlersAsync.TryGetValue(stateId,
out List>? list))
{
list = [];
m_enterHandlersAsync[stateId] = list;
}
list.Add(handler);
EnsureInstalled();
}
#pragma warning disable RCS1047 // 'Async' suffix marks the registered callback as async, not this registration method.
public void AddExitStateHandlerAsync(
uint stateId,
Func handler)
#pragma warning restore RCS1047
{
if (!m_exitHandlersAsync.TryGetValue(stateId,
out List>? list))
{
list = [];
m_exitHandlersAsync[stateId] = list;
}
list.Add(handler);
EnsureInstalled();
}
#pragma warning disable RCS1047 // 'Async' suffix marks the registered callback as async, not this registration method.
public void AddTransitionObserverAsync(
Func handler)
#pragma warning restore RCS1047
{
m_transitionObserversAsync.Add(handler);
EnsureInstalled();
}
public void AddBeforeTransitionGuard(
Func guard)
{
m_guards.Add(guard);
EnsureInstalled();
}
public void InstallCauseMethod(MethodState method, uint causeId)
{
method.OnCallMethod2 = (ctx, m, objectId, inputs, outputs) =>
m_stateMachine.DoCause(ctx, m, causeId, inputs, outputs);
}
public void AddTimedTransition(
uint fromStateId,
TimeSpan timeout,
uint transitionId,
uint causeId)
{
// Cancel any pre-existing entry for the same from-state
// before replacing it, so a re-call of WithTimedTransition
// doesn't leak a stale timer or fire it with stale ids.
CancelTimer(fromStateId);
var entry = new TimedTransitionEntry(timeout, transitionId, causeId);
m_timedTransitions[fromStateId] = entry;
EnsureInstalled();
// If the machine is already in the matching from-state,
// arm the timer now — otherwise the initial-state entry
// (applied via SetState, which doesn't fire OnAfterTransition)
// would never arm a timer.
uint currentState = ExtractStateId(
m_stateMachine.CurrentState?.Id?.Value);
if (currentState == fromStateId)
{
ArmTimer(fromStateId, entry);
}
}
private void EnsureInstalled()
{
if (m_installed)
{
return;
}
m_installed = true;
m_stateMachine.OnBeforeTransition = DispatchBefore;
m_stateMachine.OnAfterTransition = DispatchAfter;
}
private ServiceResult DispatchBefore(
ISystemContext context,
StateMachineState machine,
uint transitionId,
uint causeId,
ArrayOf inputArguments,
List? outputArguments)
{
// Capture from-state BEFORE the framework updates
// CurrentState. Stored per-thread to support concurrent
// transitions safely (each DoTransition call is fully
// synchronous on one thread).
m_pendingFromByThread[Environment.CurrentManagedThreadId] = ExtractStateId(
m_stateMachine.CurrentState?.Id?.Value);
// Builder guards run before any pre-existing OnBefore.
foreach (Func g in m_guards)
{
ServiceResult r = g(context, m_stateMachine, transitionId, causeId);
if (ServiceResult.IsBad(r))
{
// Clear the per-thread stash on veto — DispatchAfter
// won't run, so leave-no-trace.
m_pendingFromByThread.TryRemove(
Environment.CurrentManagedThreadId, out _);
return r;
}
}
if (m_originalBefore != null)
{
ServiceResult r = m_originalBefore(
context, machine, transitionId, causeId,
inputArguments, outputArguments);
if (ServiceResult.IsBad(r))
{
m_pendingFromByThread.TryRemove(
Environment.CurrentManagedThreadId, out _);
}
return r;
}
return ServiceResult.Good;
}
private ServiceResult DispatchAfter(
ISystemContext context,
StateMachineState machine,
uint transitionId,
uint causeId,
ArrayOf inputArguments,
List? outputArguments)
{
// Pre-existing OnAfter runs first so its side effects (e.g.
// stack-shipped state-change reporting) complete before the
// builder's observers see the new state.
ServiceResult? originalResult = null;
if (m_originalAfter != null)
{
originalResult = m_originalAfter(
context, machine, transitionId, causeId,
inputArguments, outputArguments);
}
m_pendingFromByThread.TryRemove(
Environment.CurrentManagedThreadId, out uint from);
uint to = ExtractStateId(m_stateMachine.CurrentState?.Id?.Value);
// Exit handlers fire first, then transition observers, then
// enter handlers (standard reactive-FSM lifecycle order).
if (from != 0 && m_exitHandlers.TryGetValue(from, out List>? exitList))
{
foreach (Action h in exitList)
{
SafeInvoke(() => h(context, m_stateMachine));
}
}
if (from != 0 && m_exitHandlersAsync.TryGetValue(from,
out List>? exitListAsync))
{
foreach (Func h in exitListAsync)
{
Schedule(h, context, fromAt: from, toAt: 0);
}
}
foreach (Action observer in m_transitionObservers)
{
SafeInvoke(() => observer(context, m_stateMachine, from, to));
}
foreach (Func
observer in m_transitionObserversAsync)
{
Schedule(observer, context, from, to);
}
if (to != 0 && m_enterHandlers.TryGetValue(to, out List>? enterList))
{
foreach (Action h in enterList)
{
SafeInvoke(() => h(context, m_stateMachine));
}
}
if (to != 0 && m_enterHandlersAsync.TryGetValue(to,
out List>? enterListAsync))
{
foreach (Func h in enterListAsync)
{
Schedule(h, context, fromAt: 0, toAt: to);
}
}
// Cancel timer armed on the from state (if any), then arm
// the timer for the to state.
if (from != 0)
{
CancelTimer(from);
}
if (to != 0 && m_timedTransitions.TryGetValue(to, out TimedTransitionEntry? armEntry))
{
ArmTimer(to, armEntry);
}
return originalResult ?? ServiceResult.Good;
}
private void ArmTimer(uint stateId, TimedTransitionEntry entry)
{
var cts = new CancellationTokenSource();
entry.Cts = cts;
entry.Timer = m_timeProvider.CreateTimer(_ =>
{
if (cts.IsCancellationRequested)
{
return;
}
try
{
m_stateMachine.DoTransition(
m_context,
entry.TransitionId,
entry.CauseId,
default,
[]);
}
catch (Exception ex)
{
Debug.WriteLine(
"StateMachineBuilder timed transition for state " +
$"{stateId} threw: {ex}");
}
}, null, entry.Timeout, Timeout.InfiniteTimeSpan);
}
private void CancelTimer(uint stateId)
{
if (m_timedTransitions.TryGetValue(stateId, out TimedTransitionEntry? entry))
{
entry.Cts?.Cancel();
entry.Timer?.Dispose();
entry.Cts = null;
entry.Timer = null;
}
}
private static uint ExtractStateId(NodeId? nodeId)
{
if (!nodeId.HasValue || nodeId.Value.IsNull)
{
return 0;
}
return nodeId.Value.TryGetValue(out uint id) ? id : 0;
}
private static void SafeInvoke(Action action)
{
try
{
action();
}
catch (Exception ex)
{
Debug.WriteLine(
$"StateMachineBuilder lifecycle handler threw: {ex}");
}
}
private void Schedule(
Func handler,
ISystemContext context,
uint fromAt,
uint toAt)
{
_ = System.Threading.Tasks.Task.Run(async () =>
{
try
{
await handler(context, m_stateMachine, CancellationToken.None).ConfigureAwait(false);
}
catch (Exception ex)
{
Debug.WriteLine(
"StateMachineBuilder async lifecycle handler " +
$"(from={fromAt}, to={toAt}) threw: {ex}");
}
});
}
private void Schedule(
Func handler,
ISystemContext context,
uint from,
uint to)
{
_ = System.Threading.Tasks.Task.Run(async () =>
{
try
{
await handler(context, m_stateMachine, from, to, CancellationToken.None).ConfigureAwait(false);
}
catch (Exception ex)
{
Debug.WriteLine(
"StateMachineBuilder async transition observer " +
$"(from={from}, to={to}) threw: {ex}");
}
});
}
private sealed class TimedTransitionEntry
{
public TimedTransitionEntry(TimeSpan timeout, uint transitionId, uint causeId)
{
Timeout = timeout;
TransitionId = transitionId;
CauseId = causeId;
}
public TimeSpan Timeout { get; }
public uint TransitionId { get; }
public uint CauseId { get; }
public CancellationTokenSource? Cts { get; set; }
public ITimer? Timer { get; set; }
}
}
}