Initial commit

This commit is contained in:
2026-07-13 09:25:40 +07:00
parent c08ff54676
commit bccfb156d7
1938 changed files with 641646 additions and 0 deletions

View File

@@ -0,0 +1,48 @@
using RobotNet10.ScriptEngine.HubContexts;
namespace RobotNet10.ScriptEngine.Models;
public class LoggerMission(Guid id, ConsoleHubContext hubContext) : RobotNet10.Script.ILogger
{
private string log = "";
private readonly Mutex mutexLog = new();
public string GetLog()
{
mutexLog.WaitOne();
var result = log;
log = ""; // Clear log after reading
mutexLog.ReleaseMutex();
return result;
}
public void LogError(string message)
{
hubContext.LogErrorToMission(id, message);
mutexLog.WaitOne();
// Format: [LEVEL] YYYY-MM-DDTHH:mm:ss.fffZ | message
log += $"[ERROR] {DateTime.UtcNow:yyyy-MM-ddTHH:mm:ss.fffZ} | {message}{Environment.NewLine}";
mutexLog.ReleaseMutex();
}
public void LogInfo(string message)
{
hubContext.LogInfoToMission(id, message);
mutexLog.WaitOne();
// Format: [LEVEL] YYYY-MM-DDTHH:mm:ss.fffZ | message
log += $"[INFO] {DateTime.UtcNow:yyyy-MM-ddTHH:mm:ss.fffZ} | {message}{Environment.NewLine}";
mutexLog.ReleaseMutex();
}
public void LogWarning(string message)
{
hubContext.LogWarningToMission(id, message);
mutexLog.WaitOne();
// Format: [LEVEL] YYYY-MM-DDTHH:mm:ss.fffZ | message
log += $"[WARN] {DateTime.UtcNow:yyyy-MM-ddTHH:mm:ss.fffZ} | {message}{Environment.NewLine}";
mutexLog.ReleaseMutex();
}
}

View File

@@ -0,0 +1,14 @@
using RobotNet10.ScriptEngine.HubContexts;
namespace RobotNet10.ScriptEngine.Models;
public class LoggerTask(string name, ConsoleHubContext hubContext) : RobotNet10.Script.ILogger
{
public string GetLog() => string.Empty;
public void LogError(string message) => hubContext.LogErrorToTask(name, message);
public void LogInfo(string message) => hubContext.LogInfoToTask(name, message);
public void LogWarning(string message) => hubContext.LogWarningToTask(name, message);
}

View File

@@ -0,0 +1,93 @@
using Microsoft.Extensions.DependencyInjection;
using RobotNet10.Script;
using RobotNet10.Script.IO;
using RobotNet10.ScriptEngine.IO;
using RobotNet10.ScriptEngine.Shared;
namespace RobotNet10.ScriptEngine.Models;
public class ScriptEngineGlobals(ILogger logger, IServiceScopeFactory scopeFactory) : IScriptGlobals
{
public RobotNet10.Script.ILogger Logger => logger;
public Guid CreateMission(string name, params object[] args)
{
using var scope = scopeFactory.CreateScope();
var missionManager = scope.ServiceProvider.GetRequiredService<MissionManager>();
var result = missionManager.CreateMission(name, args);
if (result.IsSuccess)
{
return result.Data;
}
throw new InvalidOperationException($"Failed to create mission '{name}': {result.Message}");
}
public bool CancelMission(Guid id, string reason)
{
using var scope = scopeFactory.CreateScope();
var missionManager = scope.ServiceProvider.GetRequiredService<MissionManager>();
var mission = missionManager.GetMission(id);
if (mission == null)
{
return false;
}
try
{
mission.Cancel(reason);
return true;
}
catch
{
return false;
}
}
public void DisableTask(string name)
{
using var scope = scopeFactory.CreateScope();
var taskManager = scope.ServiceProvider.GetRequiredService<TaskManager>();
var result = taskManager.DisableTask(name);
if (!result.IsSuccess)
{
throw new InvalidOperationException($"Failed to disable task '{name}': {result.Message}");
}
}
public void EnableTask(string name)
{
using var scope = scopeFactory.CreateScope();
var taskManager = scope.ServiceProvider.GetRequiredService<TaskManager>();
var result = taskManager.EnableTask(name);
if (!result.IsSuccess)
{
throw new InvalidOperationException($"Failed to enable task '{name}': {result.Message}");
}
}
// IO Connection Factory Methods
public IHttpConnection CreateHttpConnection(string baseUrl, int timeoutSeconds = 30)
{
return new HttpConnection(baseUrl, TimeSpan.FromSeconds(timeoutSeconds));
}
public IModbusTcpConnection CreateModbusTcpConnection(string ipAddress, int port = 502, byte slaveId = 1)
{
return new ModbusTcpConnection(ipAddress, port, slaveId);
}
public IProfiNetConnection CreateProfiNetConnection(string ipAddress, int slot = 1, int subslot = 1)
{
return new ProfiNetConnection(ipAddress, slot, subslot);
}
public ICcLinkIeConnection CreateCcLinkIeConnection(string ipAddress, int stationNumber = 1)
{
return new CcLinkIeConnection(ipAddress, stationNumber);
}
public IOpcUaConnection CreateOpcUaConnection(string endpointUrl)
{
return new OpcUaConnection(endpointUrl);
}
}

View File

@@ -0,0 +1,8 @@
namespace RobotNet10.ScriptEngine.Models;
public record ScriptGlobals(
IDictionary<string, object?> RobotNet,
IDictionary<string, object?> AppApis,
IDictionary<string, object?> GlobalVariables,
IDictionary<string, object?> MissionParameters
);

View File

@@ -0,0 +1,606 @@
using Appccelerate.StateMachine;
using Appccelerate.StateMachine.Machine;
using RobotNet10.Script;
using RobotNet10.ScriptEngine.Shared;
namespace RobotNet10.ScriptEngine.Models;
/// <summary>
/// Represents a mission instance with state machine management.
/// </summary>
public class ScriptMission : IDisposable
{
private readonly PassiveStateMachine<ScriptMissionState, MissionTrigger> _stateMachine;
private readonly ScriptMissionModel _model;
private readonly ScriptGlobals _globals;
private readonly ILogger _logger;
private readonly CancellationTokenSource _internalCts;
private CancellationTokenSource? _executionCts;
private Task? _executionTask;
private bool _isPaused;
private bool _isCanceling;
private bool _disposed;
private Exception? _lastError;
private readonly Lock _lockObject = new();
private ScriptMissionState _currentState;
private int _currentScore;
/// <summary>
/// Mission triggers for state machine transitions.
/// </summary>
public enum MissionTrigger
{
Start,
Cancel,
Pause,
Resume,
CompleteCanceling,
CompletePausing,
CompleteResuming,
CompleteRunning,
ErrorOccurred,
}
/// <summary>
/// Gets the unique identifier of the mission instance.
/// </summary>
public Guid Id { get; }
/// <summary>
/// Gets the name of the mission.
/// </summary>
public string Name => _model.Name;
/// <summary>
/// Gets the total score for progress tracking.
/// </summary>
public int TotalScore => _model.TotalScore;
/// <summary>
/// Gets the current score.
/// </summary>
public int CurrentScore => _currentScore;
/// <summary>
/// Gets the current state of the mission.
/// </summary>
public ScriptMissionState State => _currentState;
/// <summary>
/// Gets the last error that occurred during mission execution.
/// </summary>
public Exception? LastError => _lastError;
/// <summary>
/// Gets the log message from mission execution.
/// </summary>
public string LogMessage => _logger.GetLog();
/// <summary>
/// Gets the log message from ILogger.
/// </summary>
public string GetLog()
{
return _logger.GetLog();
}
/// <summary>
/// Gets whether the mission is currently executing.
/// </summary>
public bool IsExecuting => _executionTask != null && !_executionTask.IsCompleted;
/// <summary>
/// Initializes a new instance of the ScriptMission class.
/// </summary>
/// <param name="id">The unique identifier for this mission instance.</param>
/// <param name="model">The mission model containing mission metadata and runner.</param>
/// <param name="globals">The script globals dictionary.</param>
public ScriptMission(
Guid id,
ScriptMissionModel model,
ScriptGlobals globals)
{
Id = id;
_model = model ?? throw new ArgumentNullException(nameof(model));
_globals = globals ?? throw new ArgumentNullException(nameof(globals));
_internalCts = new CancellationTokenSource();
// Get logger from globals.ScriptRobotNet
if (_globals.RobotNet.TryGetValue("get_Logger", out object? getLogger) &&
getLogger is Func<ILogger> getLoggerFunc)
{
_logger = getLoggerFunc.Invoke();
}
else
{
throw new InvalidOperationException($"Failed to get Logger from ScriptRobotNet globals for mission '{model.Name}'");
}
var builder = new StateMachineDefinitionBuilder<ScriptMissionState, MissionTrigger>();
// Configure state machine transitions according to StateMachine_Design.md
ConfigureStateMachine(builder);
_stateMachine = builder
.WithInitialState(ScriptMissionState.Idle)
.Build()
.CreatePassiveStateMachine();
_currentState = ScriptMissionState.Idle;
_stateMachine.Start();
}
private void ConfigureStateMachine(StateMachineDefinitionBuilder<ScriptMissionState, MissionTrigger> builder)
{
// Idle state
builder.In(ScriptMissionState.Idle)
.On(MissionTrigger.Start)
.Goto(ScriptMissionState.Running);
// Running state
builder.In(ScriptMissionState.Running)
.ExecuteOnEntry(() => { _currentState = ScriptMissionState.Running; OnEnterRunning(); })
.On(MissionTrigger.Cancel)
.Goto(ScriptMissionState.Canceling)
.On(MissionTrigger.Pause)
.Goto(ScriptMissionState.Pausing)
.On(MissionTrigger.CompleteRunning)
.Goto(ScriptMissionState.Completed)
.On(MissionTrigger.ErrorOccurred)
.Goto(ScriptMissionState.Error)
.Execute(() => { _currentState = ScriptMissionState.Error; OnEnterError(); });
// Canceling state
builder.In(ScriptMissionState.Canceling)
.ExecuteOnEntry(() => { _currentState = ScriptMissionState.Canceling; OnEnterCanceling(); })
.On(MissionTrigger.CompleteCanceling)
.Goto(ScriptMissionState.Canceled)
.On(MissionTrigger.ErrorOccurred)
.Goto(ScriptMissionState.Error)
.Execute(() => { _currentState = ScriptMissionState.Error; OnEnterError(); });
// Pausing state
builder.In(ScriptMissionState.Pausing)
.ExecuteOnEntry(() => { _currentState = ScriptMissionState.Pausing; OnEnterPausing(); })
.On(MissionTrigger.CompletePausing)
.Goto(ScriptMissionState.Paused)
.On(MissionTrigger.ErrorOccurred)
.Goto(ScriptMissionState.Error)
.Execute(() => { _currentState = ScriptMissionState.Error; OnEnterError(); });
// Paused state
builder.In(ScriptMissionState.Paused)
.ExecuteOnEntry(() => { _currentState = ScriptMissionState.Paused; OnEnterPaused(); })
.On(MissionTrigger.Resume)
.Goto(ScriptMissionState.Resuming)
.On(MissionTrigger.Cancel)
.Goto(ScriptMissionState.Canceling);
// Resuming state
builder.In(ScriptMissionState.Resuming)
.ExecuteOnEntry(() => { _currentState = ScriptMissionState.Resuming; OnEnterResuming(); })
.On(MissionTrigger.CompleteResuming)
.Goto(ScriptMissionState.Running)
.On(MissionTrigger.ErrorOccurred)
.Goto(ScriptMissionState.Error)
.Execute(() => { _currentState = ScriptMissionState.Error; OnEnterError(); });
// Canceled state (terminal)
builder.In(ScriptMissionState.Canceled)
.ExecuteOnEntry(() => { _currentState = ScriptMissionState.Canceled; OnEnterCanceled(); });
// Completed state (terminal)
builder.In(ScriptMissionState.Completed)
.ExecuteOnEntry(() => { _currentState = ScriptMissionState.Completed; OnEnterCompleted(); });
// Error state (terminal)
builder.In(ScriptMissionState.Error)
.ExecuteOnEntry(() => { _currentState = ScriptMissionState.Error; OnEnterError(); });
}
#region State Machine Event Handlers
private void OnEnterRunning()
{
lock (_lockObject)
{
if (_executionTask == null || _executionTask.IsCompleted)
{
// Create new execution task
_executionCts = CancellationTokenSource.CreateLinkedTokenSource(_internalCts.Token);
_isPaused = false;
_isCanceling = false;
_currentScore = 0;
// Update CancellationToken parameters in MissionParameters to link with mission's cancellation token
// This allows script runner to receive cancellation when Cancel() is called
foreach (var paramModel in _model.Parameters)
{
if (paramModel.Type == typeof(CancellationToken))
{
// Link CancellationToken parameter with mission's internal cancellation token
_globals.MissionParameters[paramModel.Name] = _executionCts.Token;
}
}
// Use standard thread pool
_executionTask = Task.Run(() => ExecuteMissionAsync(_executionCts.Token), _executionCts.Token);
_logger.LogInfo($"Mission '{_model.Name}' started running.");
}
}
}
private void OnEnterCanceling()
{
lock (_lockObject)
{
_isCanceling = true;
_executionCts?.Cancel();
_logger.LogInfo($"Mission '{_model.Name}' is canceling...");
}
Task.Run(async () =>
{
// Wait for execution to complete cancellation
if (_executionTask != null)
{
try
{
await _executionTask;
}
catch (OperationCanceledException)
{
// Expected when canceling
}
catch (Exception ex)
{
_logger.LogError($"Mission '{_model.Name}' cancellation error: {ex.Message}");
}
}
_stateMachine.Fire(MissionTrigger.CompleteCanceling);
});
}
private void OnEnterPausing()
{
lock (_lockObject)
{
_isPaused = true;
_logger.LogInfo($"Mission '{_model.Name}' is pausing...");
}
Task.Run(async () =>
{
// Wait for current step to complete (check in NextStepHandler)
await Task.Delay(100); // Small delay to allow current step to check pause flag
_stateMachine.Fire(MissionTrigger.CompletePausing);
});
}
private void OnEnterPaused()
{
_logger.LogInfo($"Mission '{_model.Name}' is paused.");
}
private void OnEnterResuming()
{
lock (_lockObject)
{
_isPaused = false;
_logger.LogInfo($"Mission '{_model.Name}' is resuming...");
}
Task.Run(async () =>
{
// Small delay to ensure state transition
await Task.Delay(50);
_stateMachine.Fire(MissionTrigger.CompleteResuming);
});
}
private void OnEnterCanceled()
{
_logger.LogInfo($"Mission '{_model.Name}' was canceled. Final score: {_currentScore}/{TotalScore}");
}
private void OnEnterCompleted()
{
_logger.LogInfo($"Mission '{_model.Name}' completed successfully. Final score: {_currentScore}/{TotalScore}");
}
private void OnEnterError()
{
_logger.LogError($"Mission '{_model.Name}' entered error state. Last error: {_lastError?.Message}");
}
#endregion
#region Public Control Methods
/// <summary>
/// Starts the mission (transitions from Idle to Running).
/// </summary>
public void Start()
{
try
{
_stateMachine.Fire(MissionTrigger.Start);
}
catch (Exception ex)
{
_logger.LogError($"Failed to start mission '{_model.Name}': {ex.Message}");
throw;
}
}
/// <summary>
/// Cancels the mission (transitions from Running/Paused to Canceling → Canceled).
/// </summary>
public void Cancel(string reason)
{
try
{
_logger.LogWarning($"Cancellation requested with reason: {reason}");
_stateMachine.Fire(MissionTrigger.Cancel);
}
catch (Exception ex)
{
_logger.LogError($"Failed to cancel mission '{_model.Name}': {ex.Message}");
throw;
}
}
/// <summary>
/// Pauses the mission (transitions from Running to Pausing → Paused).
/// </summary>
public void Pause()
{
try
{
_stateMachine.Fire(MissionTrigger.Pause);
}
catch (Exception ex)
{
_logger.LogError($"Failed to pause mission '{_model.Name}': {ex.Message}");
throw;
}
}
/// <summary>
/// Resumes the mission (transitions from Paused to Resuming → Running).
/// </summary>
public void Resume()
{
try
{
_stateMachine.Fire(MissionTrigger.Resume);
}
catch (Exception ex)
{
_logger.LogError($"Failed to resume mission '{_model.Name}': {ex.Message}");
throw;
}
}
/// <summary>
/// Waits for the mission to reach a terminal state (Completed, Canceled, or Error).
/// </summary>
public void WaitForStop(int timeoutMs = 10000)
{
var startTime = DateTime.UtcNow;
// Only wait if mission is actually executing - if not executing, state machine issue, proceed anyway
while (IsExecuting &&
_currentState != ScriptMissionState.Completed &&
_currentState != ScriptMissionState.Canceled &&
_currentState != ScriptMissionState.Error)
{
if ((DateTime.UtcNow - startTime).TotalMilliseconds > timeoutMs)
{
// Timeout - mission is still executing (ScriptRunner blocking)
_logger.LogWarning($"Mission '{_model.Name}' is still executing after {timeoutMs}ms timeout. ScriptRunner may be blocking.");
break;
}
Thread.Sleep(50); // Check every 50ms
}
// If mission is not executing, it's effectively stopped (even if state machine didn't transition)
if (!IsExecuting)
{
return; // Mission is not executing, proceed
}
}
#endregion
#region Mission Execution
private async Task ExecuteMissionAsync(CancellationToken cancellationToken)
{
try
{
// Execute the script runner with globals
// The CancellationToken parameter in MissionParameters is already linked to _executionCts.Token
// so the script runner will receive cancellation when Cancel() is called
var result = await _model.Runner(_globals, cancellationToken);
if (result is IAsyncEnumerable<MissionStatus> statusEnumerable)
{
var enumerator = statusEnumerable.GetAsyncEnumerator(cancellationToken);
try
{
while (await enumerator.MoveNextAsync())
{
var status = enumerator.Current;
// Update score and log message
_currentScore += status.Score;
var progress = TotalScore > 0 ? 100.0 * _currentScore / TotalScore : 0.0;
_logger.LogInfo($"Mission '{_model.Name}' progress: {progress:0.##}% - {status.Message}");
// Check for pause/resume/cancel/stop via NextStepHandler
if (!await NextStepHandlerAsync(cancellationToken))
{
// Mission was canceled or stopped
return;
}
}
}
finally
{
await enumerator.DisposeAsync();
}
// Mission completed successfully
_stateMachine.Fire(MissionTrigger.CompleteRunning);
}
else
{
_logger.LogError($"Mission '{_model.Name}' runner did not return IAsyncEnumerable<MissionStatus>.");
_lastError = new InvalidOperationException("Mission runner must return IAsyncEnumerable<MissionStatus>");
_stateMachine.Fire(MissionTrigger.ErrorOccurred);
}
}
catch (OperationCanceledException)
{
// Expected when canceling
if (_isCanceling)
{
// Cancellation was requested, state machine will handle transition
return;
}
else
{
_lastError = new OperationCanceledException("Mission execution was canceled");
_stateMachine.Fire(MissionTrigger.ErrorOccurred);
}
}
catch (Exception ex)
{
_lastError = ex;
_logger.LogError($"Mission '{_model.Name}' execution error: {ex.Message}");
_stateMachine.Fire(MissionTrigger.ErrorOccurred);
}
}
/// <summary>
/// Handles state transitions during mission execution (pause/resume/cancel/stop).
/// Returns false if execution should stop, true if execution should continue.
/// </summary>
private async Task<bool> NextStepHandlerAsync(CancellationToken cancellationToken)
{
// Check for cancellation
if (cancellationToken.IsCancellationRequested || _isCanceling)
{
return false;
}
// Check for pause - wait until resumed or canceled
while (_isPaused && !cancellationToken.IsCancellationRequested && !_isCanceling)
{
// Use async delay to avoid blocking
await Task.Delay(1000, cancellationToken);
}
// Check again after pause
if (cancellationToken.IsCancellationRequested || _isCanceling)
{
return false;
}
return true;
}
#endregion
#region IDisposable
/// <summary>
/// Disposes the mission. Can be called from any state.
/// </summary>
public void Dispose()
{
if (_disposed)
{
return;
}
lock (_lockObject)
{
if (_disposed)
{
return;
}
_disposed = true;
}
// Cancel execution if running
try
{
if (_currentState == ScriptMissionState.Running ||
_currentState == ScriptMissionState.Paused ||
_currentState == ScriptMissionState.Pausing ||
_currentState == ScriptMissionState.Resuming)
{
_internalCts.Cancel();
_executionCts?.Cancel();
// Wait a bit for cancellation to complete
var cancelTimeout = TimeSpan.FromSeconds(2);
var startTime = DateTime.UtcNow;
while ((_currentState == ScriptMissionState.Running ||
_currentState == ScriptMissionState.Paused ||
_currentState == ScriptMissionState.Pausing ||
_currentState == ScriptMissionState.Resuming ||
_currentState == ScriptMissionState.Canceling) &&
(DateTime.UtcNow - startTime) < cancelTimeout)
{
Thread.Sleep(50);
}
}
}
catch
{
// Ignore errors during cancellation
}
// Wait for execution task to complete
try
{
if (_executionTask != null && !_executionTask.IsCompleted)
{
_executionTask.Wait(TimeSpan.FromSeconds(5));
}
}
catch
{
// Ignore errors
}
// Dispose resources
try
{
_internalCts?.Dispose();
_executionCts?.Dispose();
}
catch
{
// Ignore errors
}
// Stop state machine
try
{
_stateMachine.Stop();
}
catch
{
// Ignore errors
}
GC.SuppressFinalize(this);
}
#endregion
}

View File

@@ -0,0 +1,7 @@
using Microsoft.CodeAnalysis.Scripting;
using RobotNet10.Script;
namespace RobotNet10.ScriptEngine.Models;
public record ScriptMissionModel(string Name, IEnumerable<ScriptMissionParameterModel> Parameters, string Code, int TotalScore, bool IsMultipleRun, bool AutoStart, ScriptRunner<IAsyncEnumerable<MissionStatus>> Runner);

View File

@@ -0,0 +1,3 @@
namespace RobotNet10.ScriptEngine.Models;
public record ScriptMissionParameterModel(string Name, Type Type, object? DefaultValue = null);

View File

@@ -0,0 +1,555 @@
using Appccelerate.StateMachine;
using Appccelerate.StateMachine.Machine;
using RobotNet10.Script;
using RobotNet10.ScriptEngine.Shared;
using System.Diagnostics;
using System.Threading;
namespace RobotNet10.ScriptEngine.Models;
/// <summary>
/// Represents a periodic task with state machine management.
/// </summary>
public class ScriptTask : IDisposable
{
private readonly PassiveStateMachine<ScriptTaskState, TaskTrigger> _stateMachine;
private readonly ScriptTaskModel _model;
private readonly ScriptGlobals _globals;
private readonly ILogger _logger;
private Thread? _timerThread;
private volatile bool _timerThreadRunning;
private bool _isExecuting;
private bool _isPaused;
private bool _disposed;
private Exception? _lastError;
private readonly object _lockObject = new();
private ScriptTaskState _currentState;
private long _executionCount;
/// <summary>
/// Task triggers for state machine transitions.
/// </summary>
public enum TaskTrigger
{
Start,
Pause,
Resume,
Stop,
PausingCompleted,
ResumingCompleted,
StoppingCompleted,
ErrorOccurred,
}
/// <summary>
/// Gets the name of the task.
/// </summary>
public string Name => _model.Name;
/// <summary>
/// Gets the interval in seconds between task executions.
/// </summary>
public int Interval => _model.Interval;
/// <summary>
/// Gets whether the task should auto-start when engine starts.
/// </summary>
public bool AutoStart => _model.AutoStart;
/// <summary>
/// Gets the current state of the task.
/// </summary>
public ScriptTaskState State => _currentState;
/// <summary>
/// Gets the last error that occurred during task execution.
/// </summary>
public Exception? LastError => _lastError;
/// <summary>
/// Gets whether the task is currently executing.
/// </summary>
public bool IsExecuting => _isExecuting;
/// <summary>
/// Gets the number of times the task has been executed.
/// </summary>
public long ExecutionCount => _executionCount;
/// <summary>
/// Initializes a new instance of the ScriptTask class.
/// </summary>
/// <param name="model">The task model containing task metadata and runner.</param>
/// <param name="globals">The script globals dictionary.</param>
public ScriptTask(
ScriptTaskModel model,
ScriptGlobals globals)
{
_model = model ?? throw new ArgumentNullException(nameof(model));
_globals = globals ?? throw new ArgumentNullException(nameof(globals));
// Get logger from globals.ScriptRobotNet
if (_globals.RobotNet.TryGetValue("get_Logger", out object? getLogger) &&
getLogger is Func<ILogger> getLoggerFunc)
{
_logger = getLoggerFunc.Invoke();
}
else
{
throw new InvalidOperationException($"Failed to get Logger from ScriptRobotNet globals for task '{model.Name}'");
}
var builder = new StateMachineDefinitionBuilder<ScriptTaskState, TaskTrigger>();
// Configure state machine transitions according to StateMachine_Design.md
ConfigureStateMachine(builder);
_stateMachine = builder
.WithInitialState(ScriptTaskState.Idle)
.Build()
.CreatePassiveStateMachine();
_currentState = ScriptTaskState.Idle;
_stateMachine.Start();
}
private void ConfigureStateMachine(StateMachineDefinitionBuilder<ScriptTaskState, TaskTrigger> builder)
{
// Idle state
builder.In(ScriptTaskState.Idle)
.On(TaskTrigger.Start)
.Goto(ScriptTaskState.Running);
// Running state - configure entry/exit actions separately
builder.In(ScriptTaskState.Running)
.ExecuteOnEntry(() => { _currentState = ScriptTaskState.Running; OnEnterRunning(); })
.On(TaskTrigger.Pause)
.Goto(ScriptTaskState.Pausing)
.On(TaskTrigger.Stop)
.Goto(ScriptTaskState.Stopping)
.On(TaskTrigger.ErrorOccurred)
.Goto(ScriptTaskState.Error)
.Execute(() => { _currentState = ScriptTaskState.Error; OnEnterError(); });
// Pausing state
builder.In(ScriptTaskState.Pausing)
.ExecuteOnEntry(() => { _currentState = ScriptTaskState.Pausing; OnEnterPausing(); })
.ExecuteOnExit(() => OnExitPausing())
.On(TaskTrigger.PausingCompleted)
.Goto(ScriptTaskState.Paused)
.On(TaskTrigger.ErrorOccurred)
.Goto(ScriptTaskState.Error)
.Execute(() => { _currentState = ScriptTaskState.Error; OnEnterError(); });
// Paused state
builder.In(ScriptTaskState.Paused)
.ExecuteOnEntry(() => { _currentState = ScriptTaskState.Paused; OnEnterPaused(); })
.ExecuteOnExit(() => OnExitPaused())
.On(TaskTrigger.Resume)
.Goto(ScriptTaskState.Resuming)
.On(TaskTrigger.Stop)
.Goto(ScriptTaskState.Stopping);
// Resuming state
builder.In(ScriptTaskState.Resuming)
.ExecuteOnEntry(() => { _currentState = ScriptTaskState.Resuming; OnEnterResuming(); })
.ExecuteOnExit(() => OnExitResuming())
.On(TaskTrigger.ResumingCompleted)
.Goto(ScriptTaskState.Running)
.On(TaskTrigger.ErrorOccurred)
.Goto(ScriptTaskState.Error)
.Execute(() => { _currentState = ScriptTaskState.Error; OnEnterError(); });
// Stopping state
builder.In(ScriptTaskState.Stopping)
.ExecuteOnEntry(() => { _currentState = ScriptTaskState.Stopping; OnEnterStopping(); })
.On(TaskTrigger.StoppingCompleted)
.Goto(ScriptTaskState.Stopped)
.On(TaskTrigger.ErrorOccurred)
.Goto(ScriptTaskState.Error)
.Execute(() => { _currentState = ScriptTaskState.Error; OnEnterError(); });
// Stopped state
builder.In(ScriptTaskState.Stopped)
.ExecuteOnEntry(() => { _currentState = ScriptTaskState.Stopped; OnEnterStopped(); })
.On(TaskTrigger.Start)
.Goto(ScriptTaskState.Running);
// Error state
builder.In(ScriptTaskState.Error)
.ExecuteOnEntry(() => { _currentState = ScriptTaskState.Error; OnEnterError(); })
.ExecuteOnExit(() => OnExitError())
.On(TaskTrigger.Start)
.Goto(ScriptTaskState.Running);
}
#region State Machine Event Handlers
private void OnEnterRunning()
{
lock (_lockObject)
{
// Start high-priority thread with SpinWait
StartTimerThread();
}
}
private void StartTimerThread()
{
if (_timerThread == null || !_timerThread.IsAlive)
{
_timerThreadRunning = true;
_timerThread = new Thread(TimerThreadProc)
{
IsBackground = false,
Priority = ThreadPriority.Highest,
Name = $"TaskTimer-{_model.Name}"
};
_timerThread.Start();
_logger.LogInfo($"Task '{_model.Name}' started running with high-priority thread (interval: {_model.Interval}ms).");
}
}
private void TimerThreadProc()
{
_isExecuting = true;
Thread.BeginThreadAffinity();
try
{
// Convert interval from seconds to milliseconds
var intervalMs = _model.Interval;
var intervalTicks = intervalMs * TimeSpan.TicksPerMillisecond;
var stopwatch = Stopwatch.StartNew();
var nextExecutionTime = stopwatch.ElapsedTicks + intervalTicks;
var spinWait = new SpinWait();
long currentTicks = 0;
while (_timerThreadRunning)
{
currentTicks = stopwatch.ElapsedTicks;
// Check if it's time to execute
if (currentTicks >= nextExecutionTime)
{
if (_isPaused) continue;
try
{
lock (_lockObject)
{
_executionCount++;
}
// Execute the script runner with globals directly
var result = _model.Runner(_globals).GetAwaiter().GetResult();
// Handle async result if needed
if (result is Task taskResult)
{
taskResult.GetAwaiter().GetResult();
}
}
catch (Exception ex)
{
_lastError = ex;
_logger.LogError($"Task '{_model.Name}' execution error: {ex.Message}");
_stateMachine.Fire(TaskTrigger.ErrorOccurred);
break;
}
// Calculate next execution time
nextExecutionTime = currentTicks + intervalTicks;
}
spinWait.SpinOnce();
spinWait.SpinOnce();
spinWait.SpinOnce();
spinWait.SpinOnce();
spinWait.SpinOnce();
spinWait.SpinOnce();
spinWait.SpinOnce();
spinWait.SpinOnce();
spinWait.SpinOnce();
spinWait.SpinOnce();
spinWait.Reset();
}
}
finally
{
Thread.EndThreadAffinity();
_isExecuting = false;
}
}
private void OnEnterPausing()
{
// Wait for current execution to complete if running
// Then fire PausingCompleted trigger
Task.Run(async () =>
{
await WaitForExecutionComplete();
_stateMachine.Fire(TaskTrigger.PausingCompleted);
});
}
private void OnExitPausing()
{
// Set paused flag - timer continues but ExecuteTask will skip execution
_isPaused = true;
}
private void OnEnterPaused()
{
_isPaused = true;
_logger.LogInfo($"Task '{_model.Name}' paused (timer continues, execution skipped).");
}
private void OnExitPaused()
{
// Clear paused flag when exiting paused state
_isPaused = false;
}
private void OnEnterResuming()
{
// Clear paused flag immediately
_isPaused = false;
// Fire ResumingCompleted immediately (no async operation needed)
_stateMachine.Fire(TaskTrigger.ResumingCompleted);
}
private void OnExitResuming()
{
// Ensure paused flag is cleared
_isPaused = false;
}
private void OnEnterStopping()
{
// Stop timer thread, wait for current execution to complete
lock (_lockObject)
{
// Stop the timer thread loop
_timerThreadRunning = false;
}
// Wait for timer thread to finish (with timeout)
if (_timerThread != null && _timerThread.IsAlive)
{
if (!_timerThread.Join(TimeSpan.FromSeconds(2)))
{
_logger.LogWarning($"Task '{_model.Name}' timer thread did not stop within timeout. Proceeding anyway.");
}
}
Task.Run(async () =>
{
// Wait for execution to complete, but only timeout if actually executing
await WaitForExecutionComplete();
// If still executing after timeout, it's ScriptRunner blocking
// Otherwise, proceed even if state machine didn't transition
_stateMachine.Fire(TaskTrigger.StoppingCompleted);
});
}
private void OnEnterStopped()
{
_logger.LogInfo($"Task '{_model.Name}' stopped.");
}
private void OnEnterError()
{
_logger.LogError($"Task '{_model.Name}' entered error state. Last error: {_lastError?.Message}");
}
private void OnExitError()
{
_lastError = null;
}
#endregion
#region Public Control Methods
/// <summary>
/// Starts the task (transitions from Idle/Stopped/Error to Running).
/// </summary>
public void Start()
{
try
{
_stateMachine.Fire(TaskTrigger.Start);
}
catch (Exception ex)
{
_logger.LogError($"Failed to start task '{_model.Name}': {ex.Message}");
throw;
}
}
/// <summary>
/// Pauses the task (transitions from Running to Pausing → Paused).
/// </summary>
public void Pause()
{
try
{
_stateMachine.Fire(TaskTrigger.Pause);
}
catch (Exception ex)
{
_logger.LogError($"Failed to pause task '{_model.Name}': {ex.Message}");
throw;
}
}
/// <summary>
/// Resumes the task (transitions from Paused to Resuming → Running).
/// </summary>
public void Resume()
{
try
{
_stateMachine.Fire(TaskTrigger.Resume);
}
catch (Exception ex)
{
_logger.LogError($"Failed to resume task '{_model.Name}': {ex.Message}");
throw;
}
}
/// <summary>
/// Stops the task (transitions from Running/Paused to Stopping → Stopped).
/// </summary>
public void Stop()
{
try
{
_stateMachine.Fire(TaskTrigger.Stop);
}
catch (Exception ex)
{
_logger.LogError($"Failed to stop task '{_model.Name}': {ex.Message}");
throw;
}
}
#endregion
#region Task Execution
private async Task WaitForExecutionComplete()
{
// Wait for current execution to complete (max 30 seconds)
// Only timeout if task is actually executing (ScriptRunner running)
var timeout = TimeSpan.FromSeconds(30);
var startTime = DateTime.UtcNow;
while (_isExecuting && (DateTime.UtcNow - startTime) < timeout)
{
await Task.Delay(100);
}
if (_isExecuting)
{
// Task is still executing - ScriptRunner is blocking
_logger.LogWarning($"Task '{_model.Name}' execution timeout while waiting for completion. ScriptRunner may be blocking.");
}
// If task is not executing, it's effectively stopped (even if state machine didn't transition)
// No need to log - proceed silently
}
#endregion
#region IDisposable
/// <summary>
/// Disposes the task. Can be called from any state.
/// </summary>
public void Dispose()
{
if (_disposed)
{
return;
}
lock (_lockObject)
{
if (_disposed)
{
return;
}
_disposed = true;
}
// Stop the task if it's running
try
{
if (_currentState == ScriptTaskState.Running ||
_currentState == ScriptTaskState.Paused ||
_currentState == ScriptTaskState.Pausing ||
_currentState == ScriptTaskState.Resuming)
{
_stateMachine.Fire(TaskTrigger.Stop);
// Wait a bit for stopping to complete
var stopTimeout = TimeSpan.FromSeconds(2);
var startTime = DateTime.UtcNow;
while ((_currentState == ScriptTaskState.Running ||
_currentState == ScriptTaskState.Paused ||
_currentState == ScriptTaskState.Pausing ||
_currentState == ScriptTaskState.Resuming ||
_currentState == ScriptTaskState.Stopping) &&
(DateTime.UtcNow - startTime) < stopTimeout)
{
Thread.Sleep(50);
}
}
}
catch
{
// Ignore errors during stop
}
// Ensure timer thread is stopped
lock (_lockObject)
{
_timerThreadRunning = false;
}
// Wait for timer thread to finish (with timeout)
if (_timerThread != null && _timerThread.IsAlive)
{
if (!_timerThread.Join(TimeSpan.FromSeconds(2)))
{
_logger.LogWarning($"Task '{_model.Name}' timer thread did not stop within timeout during dispose.");
}
}
_timerThread = null;
// Wait for execution to complete
try
{
WaitForExecutionComplete().Wait(TimeSpan.FromSeconds(5));
}
catch
{
// Ignore errors
}
// Stop state machine
try
{
_stateMachine.Stop();
}
catch
{
// Ignore errors
}
GC.SuppressFinalize(this);
}
#endregion
}

View File

@@ -0,0 +1,5 @@
using Microsoft.CodeAnalysis.Scripting;
namespace RobotNet10.ScriptEngine.Models;
public record ScriptTaskModel(string Name, int Interval, bool AutoStart, string Code, ScriptRunner<object> Runner);

View File

@@ -0,0 +1,8 @@
using RobotNet10.ScriptEngine.Shared;
namespace RobotNet10.ScriptEngine.Models;
public record ScriptVariableModel(string Name, Type Type, object? DefaultValue, bool PublicRead, bool PublicWrite)
{
public string TypeName { get; } = ScriptHelpers.ToString(Type);
}