/* ======================================================================== * 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; } } } }