Initial commit

This commit is contained in:
2026-07-03 16:37:12 +07:00
commit 63b8c1ea8b
1931 changed files with 640587 additions and 0 deletions

View File

@@ -0,0 +1,47 @@
using RobotNet10.ScriptEngine.Shared;
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
namespace RobotNet10.ScriptEngine.Data;
#nullable disable
[Table("InstanceMissions")]
public class InstanceMission
{
[Column("Id")]
[DatabaseGenerated(DatabaseGeneratedOption.Identity)]
[Key]
[Required]
public Guid Id { get; set; }
[Column("MissionName")]
[Required]
public string MissionName { get; set; }
[Column("CreatedAt")]
[Required]
public DateTime CreatedAt { get; set; }
[Column("Parameters")]
public string Parameters { get; set; }
[Column("TotalScore")]
[Required]
public int TotalScore { get; set; }
[Column("State")]
[Required]
public ScriptMissionState State { get; set; }
[Column("Score")]
[Required]
public int Score { get; set; }
[Column("StoppedAt")]
[Required]
public DateTime StoppedAt { get; set; }
[Column("Log")]
public string Log { get; set; }
}

View File

@@ -0,0 +1,16 @@
using Microsoft.EntityFrameworkCore;
namespace RobotNet10.ScriptEngine.Data;
public class ScriptEngineDbContext(DbContextOptions<ScriptEngineDbContext> options) : DbContext(options)
{
public DbSet<InstanceMission> InstanceMissions { get; private set; }
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
base.OnModelCreating(modelBuilder);
modelBuilder.Entity<InstanceMission>()
.HasIndex(im => im.CreatedAt);
}
}

View File

@@ -0,0 +1,63 @@
namespace RobotNet10.ScriptEngine.Enums;
/// <summary>
/// Triggers for ScriptEngine state machine.
/// </summary>
public enum ScriptEngineTrigger
{
/// <summary>
/// Reset engine to Idle state.
/// </summary>
Reset,
/// <summary>
/// Build scripts from files.
/// </summary>
Build,
/// <summary>
/// Start engine (enable tasks/missions).
/// </summary>
Start,
/// <summary>
/// Stop engine.
/// </summary>
Stop,
/// <summary>
/// Initialization completed (internal trigger).
/// </summary>
InitializationCompleted,
/// <summary>
/// Resetting completed (internal trigger).
/// </summary>
ResettingCompleted,
/// <summary>
/// Building completed (internal trigger).
/// </summary>
BuildingCompleted,
/// <summary>
/// Starting completed (internal trigger).
/// </summary>
StartingCompleted,
/// <summary>
/// Stopping completed (internal trigger).
/// </summary>
StoppingCompleted,
/// <summary>
/// Build error occurred (internal trigger).
/// </summary>
BuildErrorOccurred,
/// <summary>
/// System fault occurred (internal trigger).
/// </summary>
FaultOccurred,
}

View File

@@ -0,0 +1,412 @@
using System.Collections.Concurrent;
using System.IO.Compression;
using System.Text;
using RobotNet10.ScriptEngine.Shared;
namespace RobotNet10.ScriptEngine;
/// <summary>
/// Service for managing script files and folders.
/// </summary>
public class FileManager
{
private readonly Lock _stateLockObject = new();
private ScriptEngineState _state = ScriptEngineState.Idle;
private string? _editConnectionId;
/// <summary>
/// Gets or sets the root path for script files.
/// </summary>
public string RootPath { get; set; } = "scripts";
/// <summary>
/// Gets or sets the backup path for script backups.
/// </summary>
public string BackupPath { get; set; } = "bkscripts";
/// <summary>
/// Gets the current state of the FileManager.
/// </summary>
public ScriptEngineState State => _state;
/// <summary>
/// Gets the connection ID that currently has edit permission.
/// </summary>
public string? EditConnectionId => _editConnectionId;
/// <summary>
/// Initializes a new instance of FileManager and ensures root directories exist.
/// </summary>
public FileManager()
{
if (!Directory.Exists(RootPath))
{
Directory.CreateDirectory(RootPath);
}
if (!Directory.Exists(BackupPath))
{
Directory.CreateDirectory(BackupPath);
}
}
/// <summary>
/// Checks if editing is allowed in the current state.
/// According to StateMachine_Design.md, scripts can only be edited when state is Idle.
/// </summary>
public bool IsEditAllowed()
{
return _state == ScriptEngineState.Idle;
}
/// <summary>
/// Checks if rollback is allowed in the current state.
/// </summary>
public bool IsRollbackAllowed()
{
return _state == ScriptEngineState.Idle || _state == ScriptEngineState.Ready || _state == ScriptEngineState.BuildError;
}
/// <summary>
/// Sets the state of the FileManager.
/// Note: Permission is not automatically revoked when state changes.
/// HasEditPermission will return false when state is not Idle, effectively locking the UI.
/// </summary>
public void SetState(ScriptEngineState newState)
{
lock (_stateLockObject)
{
_state = newState;
}
}
/// <summary>
/// Requests edit permission for a connection. Always succeeds and forces out the previous connection.
/// The previous connection will be notified. If state is not Idle, permission is granted but HasEditPermission will return false.
/// </summary>
/// <returns>The connection ID that previously had edit permission, or null if none.</returns>
public string? RequestEditPermission(string connectionId)
{
lock (_stateLockObject)
{
// Always grant permission to the requesting connection, forcing out any previous connection
// This handles cases where previous connection disconnected abruptly without revoking
var previousConnectionId = _editConnectionId;
_editConnectionId = connectionId;
return previousConnectionId;
}
}
/// <summary>
/// Revokes edit permission for a connection.
/// </summary>
public void RevokeEditPermission(string connectionId)
{
lock (_stateLockObject)
{
if (_editConnectionId == connectionId)
{
_editConnectionId = null;
}
}
}
/// <summary>
/// Checks if a connection has edit permission.
/// </summary>
public bool HasEditPermission(string connectionId)
{
return _editConnectionId == connectionId && IsEditAllowed();
}
/// <summary>
/// Gets the root folder structure with all files and folders.
/// </summary>
public async Task<ScriptFolderDto> GetRootFolderAsync()
{
return await Task.Run(() => BuildFolderStructure(RootPath, "Scripts", 0));
}
private static ScriptFolderDto BuildFolderStructure(string directoryPath, string folderName, int level)
{
if (!Directory.Exists(directoryPath))
{
return new ScriptFolderDto(folderName, level, [], []);
}
var folders = new List<ScriptFolderDto>();
var files = new List<ScriptFileDto>();
// Process subdirectories
foreach (var dir in Directory.GetDirectories(directoryPath).OrderBy(d => Path.GetFileName(d)))
{
var dirName = Path.GetFileName(dir);
var subFolder = BuildFolderStructure(dir, dirName, level + 1);
folders.Add(subFolder);
}
// Process files
foreach (var file in Directory.GetFiles(directoryPath).OrderBy(f => Path.GetFileName(f)))
{
var fileName = Path.GetFileName(file);
var code = File.ReadAllText(file, Encoding.UTF8);
files.Add(new ScriptFileDto(fileName, level + 1, code));
}
return new ScriptFolderDto(folderName, level, [.. folders], [.. files]);
}
/// <summary>
/// Saves file content.
/// </summary>
public async Task SaveFileAsync(string relativePath, string content, string connectionId)
{
if (!HasEditPermission(connectionId))
throw new UnauthorizedAccessException($"Connection '{connectionId}' does not have edit permission");
var fullPath = Path.Combine(RootPath, NormalizePath(relativePath));
var directory = Path.GetDirectoryName(fullPath);
if (!string.IsNullOrEmpty(directory) && !Directory.Exists(directory))
{
Directory.CreateDirectory(directory);
}
await File.WriteAllTextAsync(fullPath, content, Encoding.UTF8);
}
/// <summary>
/// Creates a new file.
/// </summary>
public async Task CreateFileAsync(string relativePath, string content, string connectionId)
{
if (!HasEditPermission(connectionId))
throw new UnauthorizedAccessException($"Connection '{connectionId}' does not have edit permission");
var fullPath = Path.Combine(RootPath, NormalizePath(relativePath));
if (File.Exists(fullPath))
throw new IOException($"File '{relativePath}' already exists");
var directory = Path.GetDirectoryName(fullPath);
if (!string.IsNullOrEmpty(directory) && !Directory.Exists(directory))
{
Directory.CreateDirectory(directory);
}
await File.WriteAllTextAsync(fullPath, content, Encoding.UTF8);
}
/// <summary>
/// Creates a new folder.
/// </summary>
public void CreateFolder(string relativePath, string connectionId)
{
if (!HasEditPermission(connectionId))
throw new UnauthorizedAccessException($"Connection '{connectionId}' does not have edit permission");
var fullPath = Path.Combine(RootPath, NormalizePath(relativePath));
if (Directory.Exists(fullPath))
throw new IOException($"Folder '{relativePath}' already exists");
Directory.CreateDirectory(fullPath);
}
/// <summary>
/// Deletes a file.
/// </summary>
public void DeleteFile(string relativePath, string connectionId)
{
if (!HasEditPermission(connectionId))
throw new UnauthorizedAccessException($"Connection '{connectionId}' does not have edit permission");
var fullPath = Path.Combine(RootPath, NormalizePath(relativePath));
if (!File.Exists(fullPath))
throw new FileNotFoundException($"File '{relativePath}' not found");
File.Delete(fullPath);
}
/// <summary>
/// Deletes a folder.
/// </summary>
public void DeleteFolder(string relativePath, string connectionId)
{
if (!HasEditPermission(connectionId))
throw new UnauthorizedAccessException($"Connection '{connectionId}' does not have edit permission");
var fullPath = Path.Combine(RootPath, NormalizePath(relativePath));
if (!Directory.Exists(fullPath))
throw new DirectoryNotFoundException($"Folder '{relativePath}' not found");
Directory.Delete(fullPath, recursive: true);
}
/// <summary>
/// Creates a backup ZIP file of all scripts.
/// </summary>
public async Task<string> CreateBackupAsync(string? backupName = null)
{
string backupFileName;
if (string.IsNullOrWhiteSpace(backupName))
{
// Default name with timestamp
var timestamp = DateTime.UtcNow.ToString("yyyy-MM-dd_HHmmss");
backupFileName = $"ScriptBackup_{timestamp}.zip";
}
else
{
// Use provided name, ensure ScriptBackup_ prefix and .zip extension
backupName = backupName.Trim();
// Remove .zip extension if present to normalize
if (backupName.EndsWith(".zip", StringComparison.OrdinalIgnoreCase))
{
backupName = backupName.Substring(0, backupName.Length - 4);
}
// Add ScriptBackup_ prefix if not present
if (!backupName.StartsWith("ScriptBackup_", StringComparison.OrdinalIgnoreCase))
{
backupFileName = $"ScriptBackup_{backupName}.zip";
}
else
{
backupFileName = $"{backupName}.zip";
}
}
var backupFilePath = Path.Combine(BackupPath, backupFileName);
if (File.Exists(backupFilePath))
File.Delete(backupFilePath);
await Task.Run(() =>
{
using var zipArchive = ZipFile.Open(backupFilePath, ZipArchiveMode.Create);
var rootFullPath = Path.GetFullPath(RootPath);
if (Directory.Exists(RootPath))
{
foreach (var file in Directory.GetFiles(RootPath, "*", SearchOption.AllDirectories))
{
var relativePath = Path.GetRelativePath(rootFullPath, file);
zipArchive.CreateEntryFromFile(file, relativePath.Replace('\\', '/'));
}
}
});
return backupFileName;
}
/// <summary>
/// Restores scripts from a backup ZIP file.
/// </summary>
public async Task RestoreBackupAsync(string backupFileName, bool replaceExisting = true)
{
if (!IsRollbackAllowed())
throw new InvalidOperationException($"Cannot restore backup in state: {_state}");
var backupFilePath = Path.Combine(BackupPath, backupFileName);
if (!File.Exists(backupFilePath))
throw new FileNotFoundException($"Backup file '{backupFileName}' not found");
await Task.Run(() =>
{
// Clear existing scripts if replacing
if (replaceExisting && Directory.Exists(RootPath))
{
Directory.Delete(RootPath, recursive: true);
}
if (!Directory.Exists(RootPath))
{
Directory.CreateDirectory(RootPath);
}
// Extract ZIP
ZipFile.ExtractToDirectory(backupFilePath, RootPath, overwriteFiles: replaceExisting);
});
}
/// <summary>
/// Lists available backup files.
/// </summary>
public ScriptBackupInfo[] ListBackups()
{
if (!Directory.Exists(BackupPath))
return [];
return [.. Directory.GetFiles(BackupPath, "ScriptBackup_*.zip")
.Select(file =>
{
var fileInfo = new FileInfo(file);
return new ScriptBackupInfo
{
FileName = Path.GetFileName(file),
Size = fileInfo.Length,
CreatedAt = fileInfo.CreationTime
};
})
.OrderByDescending(x => x.CreatedAt)];
}
/// <summary>
/// Deletes a backup file.
/// </summary>
public void DeleteBackup(string backupFileName)
{
var backupFilePath = Path.Combine(BackupPath, backupFileName);
if (!File.Exists(backupFilePath))
throw new FileNotFoundException($"Backup file '{backupFileName}' not found");
File.Delete(backupFilePath);
}
/// <summary>
/// Aggregates all script files code into a single string.
/// Files are processed in alphabetical order by path.
/// </summary>
/// <returns>The aggregated code from all script files.</returns>
public async Task<string> AggregateAllCodeAsync()
{
return await Task.Run(() =>
{
if (!Directory.Exists(RootPath))
return string.Empty;
var codeBuilder = new StringBuilder();
var allFiles = Directory.GetFiles(RootPath, "*.cs", SearchOption.AllDirectories)
.OrderBy(f => f)
.ToList();
foreach (var file in allFiles)
{
var code = File.ReadAllText(file, Encoding.UTF8);
if (!string.IsNullOrWhiteSpace(code))
{
codeBuilder.AppendLine(code);
codeBuilder.AppendLine(); // Add blank line between files
}
}
return codeBuilder.ToString().TrimEnd();
});
}
private static string NormalizePath(string path)
{
// Normalize path separators
path = path.Replace('\\', '/');
// Remove leading slash
if (path.StartsWith('/'))
path = path[1..];
// Prevent directory traversal
if (path.Contains(".."))
throw new ArgumentException("Path cannot contain '..'", nameof(path));
return path;
}
}

View File

@@ -0,0 +1,138 @@
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp;
using Microsoft.CodeAnalysis.CSharp.Syntax;
using Microsoft.CodeAnalysis.Scripting;
using RobotNet10.ScriptEngine;
using RobotNet10.ScriptEngine.Models;
using RobotNet10.ScriptEngine.Shared;
using System.Collections.Immutable;
using System.Text;
namespace RobotNet10.ScriptEngine.Helpers;
public class ScriptBuilder
{
public readonly string DllPath = "dlls";
public ScriptOptions ScriptOptions { get; private set; } = ScriptOptions.Default;
public ImmutableArray<MetadataReference> MetadataReferences { get; private set; } = [];
public readonly string UsingNamespacesScript = "";
public string VerifyGlobalsScript = "";
public string RuntimeGlobalsScript = "";
public ScriptBuilder(IScriptEngineResource scriptResource, string dllPath)
{
if (!string.IsNullOrEmpty(dllPath))
{
DllPath = dllPath;
}
UsingNamespacesScript = string.Join(Environment.NewLine, scriptResource.UsingNamespaces.Select(ns => $"using {ns};"));
var sb = new StringBuilder();
sb.AppendLine($"private Dictionary<string, object?> {nameof(ScriptGlobals.RobotNet)} = [];");
sb.AppendLine($"private Dictionary<string, object?> {nameof(ScriptGlobals.AppApis)} = [];");
sb.AppendLine($"private Dictionary<string, object?> {nameof(ScriptGlobals.GlobalVariables)} = [];");
sb.AppendLine($"private Dictionary<string, object?> {nameof(ScriptGlobals.MissionParameters)} = [];");
VerifyGlobalsScript = sb.ToString();
RuntimeGlobalsScript = ScriptHelper.BuildGlobalsScript(scriptResource.AppGlobalType);
List<MetadataReference> metadataRefs = [];
var currentDirectory = Directory.GetCurrentDirectory();
if (Directory.Exists(DllPath))
{
foreach (var dll in Directory.GetFiles(DllPath, "*.dll"))
{
metadataRefs.Add(MetadataReference.CreateFromFile(Path.Combine(currentDirectory, dll), properties: MetadataReferenceProperties.Assembly));
}
}
MetadataReferences = [.. metadataRefs];
var options = ScriptOptions.Default;
options.MetadataReferences.Clear();
ScriptOptions = options.AddReferences(MetadataReferences).AddImports(scriptResource.UsingNamespaces).WithEmitDebugInformation(false);
}
public void Build(string code, out IEnumerable<ScriptVariableModel> variables, out IEnumerable<ScriptTaskModel> tasks, out IEnumerable<ScriptMissionModel> missions)
{
try
{
var listVariables = new List<ScriptVariableModel>();
var wrappedCode = string.Join(Environment.NewLine, [UsingNamespacesScript, "public class DummyClass", "{", VerifyGlobalsScript, RuntimeGlobalsScript, code, "}"]);
var devCompilation = CSharpCompilation.Create("CodeAnalysis")
.WithReferences(MetadataReferences)
.WithOptions(new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary))
.AddSyntaxTrees(CSharpSyntaxTree.ParseText(wrappedCode));
var diagnostics = devCompilation.GetDiagnostics();
if (diagnostics.Any(d => d.Severity == DiagnosticSeverity.Error))
{
var message = new StringBuilder("Script compilation errors:\r\n");
foreach (var diag in diagnostics.Where(d => d.Severity == DiagnosticSeverity.Error))
{
var location = diag.Location;
var lineSpan = location.GetLineSpan();
message.AppendLine($"\t❌ Error: {diag.GetMessage()}");
message.AppendLine($"\t Location: Line {lineSpan.StartLinePosition.Line + 1}, Column {lineSpan.StartLinePosition.Character + 1}");
if (location.SourceTree != null && lineSpan.IsValid)
{
var line = location.SourceTree.GetText().Lines[lineSpan.StartLinePosition.Line];
message.AppendLine($"\t Code: {line.ToString().Trim()}");
}
}
throw new ScriptCompilationException(message.ToString().TrimEnd());
}
wrappedCode = string.Join(Environment.NewLine, [UsingNamespacesScript, "public class DummyClass", "{", code, "}"]);
var syntaxTree = CSharpSyntaxTree.ParseText(wrappedCode);
var root = syntaxTree.GetCompilationUnitRoot();
var runCompilation = CSharpCompilation.Create("CodeAnalysis")
.AddSyntaxTrees(syntaxTree)
.WithReferences(MetadataReferences)
.WithOptions(new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary));
var classNode = root.DescendantNodes().OfType<ClassDeclarationSyntax>().FirstOrDefault(c => c.Identifier.Text.Equals("DummyClass"));
if (classNode == null)
{
throw new ScriptCompilationException("No class named 'DummyClass' found in the script. This is an internal error.");
}
var semanticModel = runCompilation.GetSemanticModel(syntaxTree);
try
{
variables = ScriptHelper.ExportScriptVariables(classNode, semanticModel, ScriptOptions);
}
catch (Exception ex)
{
throw new ScriptCompilationException($"Failed to export script variables: {ex.Message}", ex);
}
try
{
ScriptHelper.ExportScriptTasksAndMissions(classNode, semanticModel, RuntimeGlobalsScript, ScriptOptions, out var scriptTasks, out var scriptMissions);
tasks = scriptTasks;
missions = scriptMissions;
}
catch (Exception ex)
{
throw new ScriptCompilationException($"Failed to export script tasks and missions: {ex.Message}", ex);
}
}
catch (ScriptCompilationException)
{
throw;
}
catch (Exception ex)
{
throw new ScriptCompilationException($"Unexpected error during script compilation: {ex.Message}", ex);
}
finally
{
// Only force GC if we're dealing with large scripts or many compilations
// In normal cases, let GC handle it naturally
// GC.Collect();
}
}
}

View File

@@ -0,0 +1,16 @@
namespace RobotNet10.ScriptEngine.Helpers;
/// <summary>
/// Exception thrown when script compilation fails.
/// </summary>
public class ScriptCompilationException : Exception
{
public ScriptCompilationException(string message) : base(message)
{
}
public ScriptCompilationException(string message, Exception innerException) : base(message, innerException)
{
}
}

View File

@@ -0,0 +1,704 @@
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp;
using Microsoft.CodeAnalysis.CSharp.Scripting;
using Microsoft.CodeAnalysis.CSharp.Syntax;
using Microsoft.CodeAnalysis.Scripting;
using RobotNet10.Script;
using RobotNet10.ScriptEngine.Models;
using RobotNet10.ScriptEngine.Shared;
using System.Linq.Expressions;
using System.Reflection;
using System.Text;
namespace RobotNet10.ScriptEngine.Helpers;
public static class ScriptHelper
{
public static Dictionary<string, object?> ConvertGlobalsToDictionary(object globals, Type type)
{
var globalsDic = new Dictionary<string, object?>();
foreach (var field in type.GetFields(BindingFlags.Public | BindingFlags.Instance))
{
globalsDic.TryAdd(field.Name, field.GetValue(globals));
}
foreach (var prop in type.GetProperties(BindingFlags.Public | BindingFlags.Instance))
{
if (prop.GetIndexParameters().Length == 0)
{
// Forward getter if available
if (prop.GetGetMethod() != null)
{
var getter = Delegate.CreateDelegate(
Expression.GetDelegateType([prop.PropertyType]),
globals,
prop.GetGetMethod()!
);
globalsDic.TryAdd($"get_{prop.Name}", getter);
}
// Forward setter if available
if (prop.GetSetMethod() != null)
{
var setter = Delegate.CreateDelegate(
Expression.GetDelegateType([prop.PropertyType, typeof(void)]),
globals,
prop.GetSetMethod()!
);
globalsDic.TryAdd($"set_{prop.Name}", setter);
}
}
else
{
// Handle indexers (properties with parameters)
var indexParams = prop.GetIndexParameters().Select(p => p.ParameterType).ToArray();
// Forward indexer getter if available
if (prop.GetGetMethod() != null)
{
var getterParamTypes = indexParams.Concat([prop.PropertyType]).ToArray();
var getterDelegate = Delegate.CreateDelegate(
Expression.GetDelegateType(getterParamTypes),
globals,
prop.GetGetMethod()!
);
globalsDic.TryAdd($"get_{prop.Name}_indexer", getterDelegate);
}
// Forward indexer setter if available
if (prop.GetSetMethod() != null)
{
var setterParamTypes = indexParams.Concat([prop.PropertyType, typeof(void)]).ToArray();
var setterDelegate = Delegate.CreateDelegate(
Expression.GetDelegateType(setterParamTypes),
globals,
prop.GetSetMethod()!
);
globalsDic.TryAdd($"set_{prop.Name}_indexer", setterDelegate);
}
}
}
foreach (var method in type.GetMethods(BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly))
{
if (!method.IsSpecialName)
{
var parameters = string.Join(", ", method.GetParameters().Select(p => $"{p.ParameterType.FullName} {p.Name}"));
var args = string.Join(", ", method.GetParameters().Select(p => p.Name));
var returnType = method.ReturnType == typeof(void) ? "void" : method.ReturnType.FullName;
var paramTypes = string.Join(",", method.GetParameters().Select(p => p.ParameterType.FullName));
var methodKey = $"{method.Name}({paramTypes})";
var del = Delegate.CreateDelegate(
Expression.GetDelegateType([.. method.GetParameters().Select(p => p.ParameterType), method.ReturnType]),
globals,
method
);
globalsDic.TryAdd(methodKey, del);
}
}
return globalsDic;
}
public static string BuildGlobalsScript(Type globalType)
{
var sb = new StringBuilder();
sb.AppendLine(BuildGlobalsScript(typeof(IScriptGlobals), nameof(ScriptGlobals.RobotNet)));
sb.AppendLine(BuildGlobalsScript(globalType, nameof(ScriptGlobals.AppApis)));
return sb.ToString();
}
public static string BuildGlobalsScript(Type globalType, string nameOfGlobals)
{
var sb = new StringBuilder();
foreach (var field in globalType.GetFields(BindingFlags.Public | BindingFlags.Instance))
{
sb.AppendLine($@"{ScriptHelpers.ToString(field.FieldType)} {field.Name}
{{
get => ({ScriptHelpers.ToString(field.FieldType)}){nameOfGlobals}[""{field.Name}""];
set => {nameOfGlobals}[""{field.Name}""] = value;
}}");
}
foreach (var prop in globalType.GetProperties(BindingFlags.Public | BindingFlags.Instance))
{
if (prop.GetIndexParameters().Length == 0)
{
var hasGetter = prop.GetGetMethod() != null;
var hasSetter = prop.GetSetMethod() != null;
if (hasGetter || hasSetter)
{
var propBuilder = new StringBuilder();
propBuilder.AppendLine($"{ScriptHelpers.ToString(prop.PropertyType)} {prop.Name}");
propBuilder.AppendLine("{");
if (hasGetter)
propBuilder.AppendLine($@" get => ((Func<{ScriptHelpers.ToString(prop.PropertyType)}>){nameOfGlobals}[""get_{prop.Name}""])();");
if (hasSetter)
propBuilder.AppendLine($@" set => ((Action<{ScriptHelpers.ToString(prop.PropertyType)}>){nameOfGlobals}[""set_{prop.Name}""])(value);");
propBuilder.AppendLine("}");
sb.AppendLine(propBuilder.ToString());
}
}
else
{
// Handle indexers (properties with parameters)
var indexParams = prop.GetIndexParameters();
var paramDecl = string.Join(", ", indexParams.Select(p => $"{ScriptHelpers.ToString(p.ParameterType)} {p.Name}"));
var paramNames = string.Join(", ", indexParams.Select(p => p.Name));
var getterDelegateType = $"Func<{string.Join(", ", indexParams.Select(p => ScriptHelpers.ToString(p.ParameterType)).Concat([ScriptHelpers.ToString(prop.PropertyType)]))}>";
var setterDelegateType = $"Action<{string.Join(", ", indexParams.Select(p => ScriptHelpers.ToString(p.ParameterType)).Concat([ScriptHelpers.ToString(prop.PropertyType)]))}>";
var propBuilder = new StringBuilder();
propBuilder.AppendLine($"{ScriptHelpers.ToString(prop.PropertyType)} this[{paramDecl}]");
propBuilder.AppendLine("{");
if (prop.GetGetMethod() != null)
propBuilder.AppendLine($@" get => (({getterDelegateType}){nameOfGlobals}[""get_{prop.Name}_indexer""])({paramNames});");
if (prop.GetSetMethod() != null)
propBuilder.AppendLine($@" set => (({setterDelegateType}){nameOfGlobals}[""set_{prop.Name}_indexer""])({(string.IsNullOrEmpty(paramNames) ? "value" : paramNames + ", value")});");
propBuilder.AppendLine("}");
sb.AppendLine(propBuilder.ToString());
}
}
foreach (var method in globalType.GetMethods(BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly))
{
if (!method.IsSpecialName)
{
var parameters = method.GetParameters() ?? [];
var parametersStr = string.Join(", ", parameters.Select(ScriptHelpers.ToString));
var args = string.Join(", ", parameters.Select(p => p.Name));
var returnType = ScriptHelpers.ToString(method.ReturnType);
var paramTypes = string.Join(",", parameters.Select(p => p.ParameterType.FullName));
var methodKey = $"{method.Name}({paramTypes})";
var methodType = method.ReturnType == typeof(void)
? (parameters.Length == 0 ? "Action" : $"Action<{string.Join(", ", parameters.Select(p => ScriptHelpers.ToString(p.ParameterType)))}>")
: $"Func<{string.Join(", ", parameters.Select(p => ScriptHelpers.ToString(p.ParameterType)).Concat([ScriptHelpers.ToString(method.ReturnType)]))}>";
sb.AppendLine($@"{returnType} {method.Name}({parametersStr}) => (({methodType}){nameOfGlobals}[""{methodKey}""]){(string.IsNullOrEmpty(args) ? "()" : $"({args})")};");
}
}
return sb.ToString();
}
public static IEnumerable<ScriptVariableModel> ExportScriptVariables(ClassDeclarationSyntax classNode, SemanticModel semanticModel, ScriptOptions scriptOptions)
{
List<ScriptVariableModel> variables = [];
var fields = classNode.Members.OfType<FieldDeclarationSyntax>();
foreach (var field in fields)
{
Type? resolvedType = semanticModel.ToSystemType(field.Declaration.Type);
if (resolvedType == null)
{
var fieldName = field.Declaration.Variables.FirstOrDefault()?.Identifier.Text ?? "unknown";
throw new ScriptCompilationException($"Failed to resolve type for field '{fieldName}': {field.Declaration.Type.ToFullString()}");
}
// Check if the field has VariableAttribute
VariableAttribute? varAttr = null;
foreach (var attrList in field.AttributeLists)
{
foreach (var attr in attrList.Attributes)
{
var attrType = semanticModel.GetTypeAttribute(attr);
if (attrType == VariableAttributeType)
{
varAttr = semanticModel.GetConstantAttribute(attr, attrType) as RobotNet10.Script.VariableAttribute;
break;
}
}
if (varAttr != null) break;
}
foreach (var variable in field.Declaration.Variables)
{
var name = variable.Identifier.Text;
if (string.IsNullOrEmpty(name)) continue;
if (variable.Initializer is null)
{
var value = resolvedType.IsValueType ? Activator.CreateInstance(resolvedType) : null;
variables.Add(new ScriptVariableModel(name, resolvedType, value, varAttr != null, varAttr?.PublicWrite ?? false));
}
else
{
var constant = semanticModel.GetConstantValue(variable.Initializer.Value);
if (constant.HasValue)
{
try
{
var value = Convert.ChangeType(constant.Value, resolvedType);
variables.Add(new ScriptVariableModel(name, resolvedType, value, varAttr != null, varAttr?.PublicWrite ?? false));
}
catch (Exception ex)
{
throw new ScriptCompilationException($"Failed to convert value of variable '{name}' = \"{constant.Value}\" to type '{resolvedType.Name}': {ex.Message}", ex);
}
}
else
{
var code = variable.Initializer.Value.ToFullString();
object? value;
if (string.IsNullOrEmpty(code))
{
value = resolvedType.IsValueType ? Activator.CreateInstance(resolvedType) : null;
}
else
{
value = CSharpScript.EvaluateAsync<object>(code, scriptOptions).GetAwaiter().GetResult();
}
variables.Add(new ScriptVariableModel(name, resolvedType, value, varAttr != null, varAttr?.PublicWrite ?? false));
}
}
}
}
// Check auto-properties (properties with both getter and setter, no body) and add them to variables
var properties = classNode.Members.OfType<PropertyDeclarationSyntax>();
foreach (var prop in properties)
{
// Kiểm tra có cả getter và setter
var accessors = prop.AccessorList?.Accessors.ToList() ?? [];
bool hasGetter = accessors?.Any(a => a.Kind() == SyntaxKind.GetAccessorDeclaration) == true;
bool hasSetter = accessors?.Any(a => a.Kind() == SyntaxKind.SetAccessorDeclaration) == true;
// Kiểm tra auto-property: cả getter và setter đều không có body và không phải expression-bodied
bool isAutoProperty = hasGetter && hasSetter &&
accessors!.All(a => a.Body == null && a.ExpressionBody == null) &&
prop.ExpressionBody == null;
if (isAutoProperty)
{
var name = prop.Identifier.Text;
var type = semanticModel.ToSystemType(prop.Type);
if (type == null)
{
throw new ScriptCompilationException($"Failed to resolve type for auto-property '{name}': {prop.Type.ToFullString()}");
}
// Giá trị mặc định của auto-property là default(T)
object? value = type.IsValueType ? Activator.CreateInstance(type) : null;
variables.Add(new ScriptVariableModel(name, type, value, false, false));
}
}
return variables;
}
public static void ExportScriptTasksAndMissions(ClassDeclarationSyntax classNode,
SemanticModel semanticModel,
string globalScript,
ScriptOptions scriptOptions,
out List<ScriptTaskModel> tasks,
out List<ScriptMissionModel> missions)
{
tasks = [];
missions = [];
var methods = classNode.Members.OfType<MethodDeclarationSyntax>();
foreach (var method in methods)
{
bool attrDone = false;
foreach (var attrList in method.AttributeLists)
{
foreach (var attr in attrList.Attributes)
{
var attrType = semanticModel.GetTypeAttribute(attr);
if (attrType == TaskAttributeType)
{
attrDone = true;
if (semanticModel.GetConstantAttribute(attr, attrType) is not RobotNet10.Script.TaskAttribute taskAttr)
{
throw new ScriptCompilationException($"Failed to get TaskAttribute from method '{method.Identifier.Text}'. Ensure the attribute has valid parameters.");
}
// Check if method returns Task or Task<T>
var returnType = semanticModel.ToSystemType(method.ReturnType);
bool isTask = returnType == typeof(System.Threading.Tasks.Task);
//|| (returnType != null && returnType.IsGenericType && returnType.GetGenericTypeDefinition() == typeof(System.Threading.Tasks.Task<>));
bool isVoid = returnType == typeof(void);
if (method.ParameterList.Parameters.Count > 0)
{
throw new ScriptCompilationException($"Task method '{method.Identifier.Text}' with [Task] attribute must have no parameters. Found {method.ParameterList.Parameters.Count} parameter(s).");
}
if (!(isTask || isVoid))
{
var actualReturnType = returnType?.Name ?? "unknown";
throw new ScriptCompilationException($"Task method '{method.Identifier.Text}' with [Task] attribute must return void or Task. Found return type: {actualReturnType}.");
}
var code = ExtractRelatedCodeForScriptRunner(classNode, method, globalScript, $"{(isTask ? "await " : "")}{method.Identifier.Text}();");
var script = CSharpScript.Create(code, scriptOptions, globalsType: typeof(ScriptGlobals));
tasks.Add(new ScriptTaskModel(method.Identifier.Text,
taskAttr.Interval,
taskAttr.AutoStart,
method.ToFullString(),
script.CreateDelegate()));
break;
}
else if (attrType == MissionAttributeType)
{
attrDone = true;
if (semanticModel.GetConstantAttribute(attr, attrType) is not RobotNet10.Script.MissionAttribute missionAttr)
{
throw new ScriptCompilationException($"Failed to get MissionAttribute from method '{method.Identifier.Text}'. Ensure the attribute has valid parameters.");
}
var returnType = semanticModel.ToSystemType(method.ReturnType);
if (returnType is null)
{
throw new ScriptCompilationException($"Failed to resolve return type for mission method '{method.Identifier.Text}'.");
}
if (returnType != MissionReturnType)
{
throw new ScriptCompilationException($"Mission method '{method.Identifier.Text}' with [Mission] attribute must return IAsyncEnumerable<MissionStatus>. Found return type: {returnType.Name}.");
}
var inputParameters = new List<string>();
var parameters = new List<ScriptMissionParameterModel>();
bool hasCancellationTokenParameter = false;
foreach (var param in method.ParameterList.Parameters)
{
if (param.Type is null)
{
throw new ScriptCompilationException($"Parameter '{param.Identifier.Text}' in mission method '{method.Identifier.Text}' has no type specified.");
}
var paramType = semanticModel.ToSystemType(param.Type);
if (paramType == null)
{
throw new ScriptCompilationException($"Failed to resolve type for parameter '{param.Identifier.Text}' in mission method '{method.Identifier.Text}'. Type: {param.Type.ToFullString()}");
}
if (paramType == typeof(CancellationToken))
{
if (hasCancellationTokenParameter)
{
throw new ScriptCompilationException($"Mission method '{method.Identifier.Text}' has multiple CancellationToken parameters, which is not allowed. Only one CancellationToken parameter is supported.");
}
hasCancellationTokenParameter = true;
}
else if (!ScriptHelpers.SupportedTypes.Values.Contains(paramType))
{
throw new ScriptCompilationException($"Parameter type '{paramType.Name}' for parameter '{param.Identifier.Text}' in mission method '{method.Identifier.Text}' is not supported. Supported types: {string.Join(", ", ScriptHelpers.SupportedTypes.Values.Select(t => t.Name))}");
}
// lấy default value nếu có
object? defaultValue = null;
if (param.Default is EqualsValueClauseSyntax equalsValue)
{
var constValue = semanticModel.GetConstantValue(equalsValue.Value);
if (constValue.HasValue)
{
defaultValue = constValue.Value;
}
}
//inputParameters.Add($@"({paramType.FullName})parameters["""+param.Identifier.Text+"""]");
inputParameters.Add($@"({paramType.FullName}){nameof(ScriptGlobals.MissionParameters)}[""{param.Identifier.Text}""]");
parameters.Add(new ScriptMissionParameterModel(param.Identifier.Text, paramType, defaultValue));
}
var execScript = $"return {method.Identifier.Text}({string.Join(", ", inputParameters)});";
var code = ExtractRelatedCodeForScriptRunner(classNode, method, globalScript, execScript);
var script = CSharpScript.Create<IAsyncEnumerable<MissionStatus>>(code, scriptOptions, globalsType: typeof(ScriptGlobals));
missions.Add(new ScriptMissionModel(method.Identifier.Text,
parameters,
method.ToFullString(),
missionAttr.TotalScore,
missionAttr.IsMultipleRun,
missionAttr.AutoStart,
script.CreateDelegate()));
break;
}
}
if (attrDone) break;
}
}
}
private static string ExtractRelatedCodeForScriptRunner(ClassDeclarationSyntax classNode, MethodDeclarationSyntax rootMethod, string globalScript, string execScript)
{
var allMethods = classNode.Members.OfType<MethodDeclarationSyntax>().ToList();
var allFields = classNode.Members.OfType<FieldDeclarationSyntax>().ToList();
var allProperties = classNode.Members.OfType<PropertyDeclarationSyntax>().ToList();
var allNestedTypes = classNode.Members
.Where(m => m is ClassDeclarationSyntax || m is StructDeclarationSyntax || m is InterfaceDeclarationSyntax || m is EnumDeclarationSyntax)
.ToList();
// 1. BFS: method + non-auto-property
var usedMethodNames = new HashSet<string>();
var usedPropertyNames = new HashSet<string>();
var methodQueue = new Queue<MemberDeclarationSyntax>();
var collectedMethods = new List<MethodDeclarationSyntax>();
var collectedNonAutoProperties = new List<PropertyDeclarationSyntax>();
methodQueue.Enqueue(rootMethod);
while (methodQueue.Count > 0)
{
var member = methodQueue.Dequeue();
if (member is MethodDeclarationSyntax method)
{
if (!usedMethodNames.Add(method.Identifier.Text))
continue;
collectedMethods.Add(method);
// Tìm các method/property được gọi trong method này
// Sử dụng InvocationExpressionSyntax và MemberAccessExpressionSyntax để tìm chính xác hơn
var invocationExpressions = method.DescendantNodes()
.OfType<InvocationExpressionSyntax>()
.Select(inv => inv.Expression)
.OfType<MemberAccessExpressionSyntax>()
.Select(ma => ma.Name.Identifier.Text)
.Distinct();
var memberAccessExpressions = method.DescendantNodes()
.OfType<MemberAccessExpressionSyntax>()
.Select(ma => ma.Name.Identifier.Text)
.Distinct();
var identifierNames = method.DescendantNodes()
.OfType<IdentifierNameSyntax>()
.Where(id => id.Parent is not MemberAccessExpressionSyntax) // Exclude already handled member accesses
.Select(id => id.Identifier.Text)
.Distinct();
var allInvokedNames = invocationExpressions
.Concat(memberAccessExpressions)
.Concat(identifierNames)
.Distinct();
foreach (var name in allInvokedNames)
{
// Method
var nextMethod = allMethods.FirstOrDefault(m => m.Identifier.Text == name);
if (nextMethod != null && !usedMethodNames.Contains(name))
methodQueue.Enqueue(nextMethod);
// Property
var nextProp = allProperties.FirstOrDefault(p => p.Identifier.Text == name);
if (nextProp != null && !usedPropertyNames.Contains(name))
methodQueue.Enqueue(nextProp);
}
}
else if (member is PropertyDeclarationSyntax prop)
{
if (!usedPropertyNames.Add(prop.Identifier.Text))
continue;
// Auto-property: bỏ qua, sẽ xử lý sau
var accessors = prop.AccessorList?.Accessors.ToList() ?? [];
bool hasGetter = accessors.Any(a => a.Kind() == SyntaxKind.GetAccessorDeclaration);
bool hasSetter = accessors.Any(a => a.Kind() == SyntaxKind.SetAccessorDeclaration);
bool isAutoProperty = hasGetter && hasSetter &&
accessors.All(a => a.Body == null && a.ExpressionBody == null) &&
prop.ExpressionBody == null;
if (isAutoProperty)
continue;
collectedNonAutoProperties.Add(prop);
// Tìm các method/property/field được gọi trong property này
// Sử dụng InvocationExpressionSyntax và MemberAccessExpressionSyntax để tìm chính xác hơn
var invocationExpressions = prop.DescendantNodes()
.OfType<InvocationExpressionSyntax>()
.Select(inv => inv.Expression)
.OfType<MemberAccessExpressionSyntax>()
.Select(ma => ma.Name.Identifier.Text)
.Distinct();
var memberAccessExpressions = prop.DescendantNodes()
.OfType<MemberAccessExpressionSyntax>()
.Select(ma => ma.Name.Identifier.Text)
.Distinct();
var identifierNames = prop.DescendantNodes()
.OfType<IdentifierNameSyntax>()
.Where(id => id.Parent is not MemberAccessExpressionSyntax) // Exclude already handled member accesses
.Select(id => id.Identifier.Text)
.Distinct();
var allInvokedNames = invocationExpressions
.Concat(memberAccessExpressions)
.Concat(identifierNames)
.Distinct();
foreach (var name in allInvokedNames)
{
// Method
var nextMethod = allMethods.FirstOrDefault(m => m.Identifier.Text == name);
if (nextMethod != null && !usedMethodNames.Contains(name))
methodQueue.Enqueue(nextMethod);
// Property
var nextProp = allProperties.FirstOrDefault(p => p.Identifier.Text == name);
if (nextProp != null && !usedPropertyNames.Contains(name))
methodQueue.Enqueue(nextProp);
}
}
}
// 2. Collect all used member names (from all collected methods & non-auto-properties)
var usedMemberNames = new HashSet<string>();
foreach (var method in collectedMethods)
{
foreach (var id in method.DescendantNodes().OfType<IdentifierNameSyntax>().Select(id => id.Identifier.Text))
usedMemberNames.Add(id);
}
foreach (var prop in collectedNonAutoProperties)
{
foreach (var id in prop.DescendantNodes().OfType<IdentifierNameSyntax>().Select(id => id.Identifier.Text))
usedMemberNames.Add(id);
}
// 3. Collect fields
var relatedFields = new List<string>();
foreach (var field in allFields)
{
foreach (var variable in field.Declaration.Variables)
{
if (usedMemberNames.Contains(variable.Identifier.Text))
{
var varType = field.Declaration.Type.ToString();
var varName = variable.Identifier.Text;
var propertyCode = $@"public {varType} {varName}
{{
get => ({varType}){nameof(ScriptGlobals.GlobalVariables)}[""{varName}""];
set => {nameof(ScriptGlobals.GlobalVariables)}[""{varName}""] = value;
}}";
relatedFields.Add(propertyCode.Trim());
}
}
}
// 4. Collect auto-properties
var relatedAutoProperties = new List<string>();
foreach (var prop in allProperties)
{
var accessors = prop.AccessorList?.Accessors.ToList() ?? [];
bool hasGetter = accessors.Any(a => a.Kind() == SyntaxKind.GetAccessorDeclaration);
bool hasSetter = accessors.Any(a => a.Kind() == SyntaxKind.SetAccessorDeclaration);
bool isAutoProperty = hasGetter && hasSetter &&
accessors.All(a => a.Body == null && a.ExpressionBody == null) &&
prop.ExpressionBody == null;
if (isAutoProperty && (usedMemberNames.Contains(prop.Identifier.Text) || usedPropertyNames.Contains(prop.Identifier.Text)))
{
var propType = prop.Type.ToString();
var propName = prop.Identifier.Text;
var propertyCode = $@"public {propType} {propName}
{{
get => ({propType}){nameof(ScriptGlobals.GlobalVariables)}[""{propName}""];
set => {nameof(ScriptGlobals.GlobalVariables)}[""{propName}""] = value;
}}";
relatedAutoProperties.Add(propertyCode.Trim());
}
}
// 5. Collect nested types if referenced
var usedNestedTypeNames = new HashSet<string>(usedMemberNames);
var relatedNestedTypes = allNestedTypes
.Where(nt =>
{
if (nt is BaseTypeDeclarationSyntax btd)
return usedNestedTypeNames.Contains(btd.Identifier.Text);
return false;
})
.Select(nt => nt.NormalizeWhitespace().ToFullString())
.ToList();
// 6. Compose the script
var sb = new StringBuilder();
sb.AppendLine(globalScript);
foreach (var nt in relatedNestedTypes)
sb.AppendLine(nt);
foreach (var f in relatedFields)
sb.AppendLine(f);
foreach (var p in relatedAutoProperties)
sb.AppendLine(p);
foreach (var p in collectedNonAutoProperties)
sb.AppendLine(p.NormalizeWhitespace().ToFullString());
foreach (var m in collectedMethods)
sb.AppendLine(m.NormalizeWhitespace().ToFullString());
sb.AppendLine(execScript);
return sb.ToString();
}
private static Type? GetTypeAttribute(this SemanticModel semanticModel, AttributeSyntax attrSynctax)
{
var typeInfo = semanticModel.GetTypeInfo(attrSynctax);
if (typeInfo.Type is null) return null;
string metadataName = typeInfo.Type.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat).Replace("global::", "");
return ScriptHelpers.ResolveTypeFromString(metadataName);
}
private static object? GetConstantAttribute(this SemanticModel semanticModel, AttributeSyntax attrSynctax, Type type)
{
var args = new List<object?>();
foreach (var arg in attrSynctax.ArgumentList?.Arguments ?? default)
{
var constValue = semanticModel.GetConstantValue(arg.Expression);
if (constValue.HasValue)
args.Add(constValue.Value);
else
args.Add(null); // fallback nếu không phân giải được
}
// Find the constructor with the same number or more parameters (with optional)
var ctors = type.GetConstructors();
foreach (var ctor in ctors)
{
var parameters = ctor.GetParameters();
if (args.Count <= parameters.Length)
{
// Fill missing optional parameters with their default values
var finalArgs = args.ToList();
for (int i = args.Count; i < parameters.Length; i++)
{
if (parameters[i].IsOptional)
finalArgs.Add(parameters[i].DefaultValue);
else
goto NextCtor; // Not enough arguments and not optional
}
return ctor.Invoke([.. finalArgs]);
}
NextCtor:;
}
return null;
}
private static Type? ToSystemType(this SemanticModel semanticModel, TypeSyntax typeSyntax)
{
var typeSymbol = semanticModel.GetTypeInfo(typeSyntax).Type;
if (typeSymbol is null) return null;
if (typeSyntax is PredefinedTypeSyntax predefinedType
&& ScriptHelpers.SupportedTypes.TryGetValue(predefinedType.Keyword.Text, out var systemType))
{
return systemType;
}
string metadataName = typeSymbol.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat).Replace("global::", "");
return metadataName.Equals("void") ? typeof(void) : ScriptHelpers.ResolveTypeFromString(metadataName);
}
private static readonly Type TaskAttributeType = typeof(RobotNet10.Script.TaskAttribute);
private static readonly Type MissionAttributeType = typeof(RobotNet10.Script.MissionAttribute);
private static readonly Type MissionReturnType = typeof(IAsyncEnumerable<RobotNet10.Script.MissionStatus>);
private static readonly Type VariableAttributeType = typeof(RobotNet10.Script.VariableAttribute);
}

View File

@@ -0,0 +1,22 @@
using Microsoft.AspNetCore.SignalR;
using RobotNet10.ScriptEngine.Hubs;
namespace RobotNet10.ScriptEngine.HubContexts;
public class ConsoleHubContext(IHubContext<ConsoleHub> hubContext)
{
private void Log(string level, string message, params string[] groups)
{
Task.Run(async Task? () => await hubContext.Clients.Groups([.. groups, "alls"]).SendAsync(level, message)).ConfigureAwait(false);
}
public void LogError(string message) => Log("Error", message);
public void LogInfo(string message) => Log("Info", message);
public void LogWarning(string message) => Log("Warning", message);
public void LogErrorToTask(string name, string message) => Log("Error", message, $"task-{name}");
public void LogInfoToTask(string name, string message) => Log("Info", message, $"task-{name}");
public void LogWarningToTask(string name, string message) => Log("Warning", message, $"task-{name}");
public void LogErrorToMission(Guid missionId, string message) => Log("Error", message, $"mission-{missionId}");
public void LogInfoToMission(Guid missionId, string message) => Log("Info", message, $"mission-{missionId}");
public void LogWarningToMission(Guid missionId, string message) => Log("Warning", message, $"mission-{missionId}");
}

View File

@@ -0,0 +1,15 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.SignalR;
namespace RobotNet10.ScriptEngine.Hubs;
[Authorize]
public class ConsoleHub : Hub
{
public Task RegisterTask(string name) => Groups.AddToGroupAsync(Context.ConnectionId, $"task-{name}");
public Task UnregisterTask(string name) => Groups.RemoveFromGroupAsync(Context.ConnectionId, $"task-{name}");
public Task RegisterMission(Guid missionId) => Groups.AddToGroupAsync(Context.ConnectionId, $"mission-{missionId}");
public Task UnregisterMission(Guid missionId) => Groups.RemoveFromGroupAsync(Context.ConnectionId, $"mission-{missionId}");
public Task RegisterAll() => Groups.AddToGroupAsync(Context.ConnectionId, "alls");
public Task UnregisterAll() => Groups.RemoveFromGroupAsync(Context.ConnectionId, "alls");
}

View File

@@ -0,0 +1,302 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.SignalR;
using RobotNet10.ScriptEngine;
using RobotNet10.ScriptEngine.HubContexts;
using RobotNet10.ScriptEngine.Shared;
namespace RobotNet10.ScriptEngine.Hubs;
/// <summary>
/// SignalR Hub for managing script files.
/// </summary>
[Authorize]
public class FileManagerHub(FileManager _fileManager, ConsoleHubContext _consoleHubContext) : Hub
{
/// <summary>
/// Called when a client disconnects.
/// </summary>
public override Task OnDisconnectedAsync(Exception? exception)
{
// Revoke edit permission if this connection had it
_fileManager.RevokeEditPermission(Context.ConnectionId);
return base.OnDisconnectedAsync(exception);
}
/// <summary>
/// Gets the root folder structure with all files and folders.
/// </summary>
public async Task<ScriptFolderDto> GetRootFolder()
{
return await _fileManager.GetRootFolderAsync();
}
/// <summary>
/// Gets the current state of the FileManager.
/// </summary>
public Task<ScriptEngineState> GetState()
{
return Task.FromResult(_fileManager.State);
}
/// <summary>
/// Requests edit permission. If another connection has permission, it will be revoked and notified.
/// </summary>
public Task<bool> RequestEditPermission()
{
var previousConnectionId = _fileManager.RequestEditPermission(Context.ConnectionId);
if (previousConnectionId != null && previousConnectionId != Context.ConnectionId)
{
// Notify the previous connection that their permission was revoked
// Only notify if it's a different connection (not the same one reconnecting)
try
{
_ = Clients.Client(previousConnectionId).SendAsync("EditPermissionRevoked", Context.UserIdentifier);
}
catch
{
// Ignore if connection is already disconnected
}
}
return Task.FromResult(true);
}
/// <summary>
/// Revokes edit permission for the current connection.
/// </summary>
public Task RevokeEditPermission()
{
_fileManager.RevokeEditPermission(Context.ConnectionId);
return Task.CompletedTask;
}
/// <summary>
/// Checks if the current connection has edit permission.
/// </summary>
public Task<bool> HasEditPermission()
{
return Task.FromResult(_fileManager.HasEditPermission(Context.ConnectionId));
}
/// <summary>
/// Saves file content.
/// </summary>
public async Task SaveFile(string relativePath, string content)
{
try
{
await _fileManager.SaveFileAsync(relativePath, content, Context.ConnectionId);
_consoleHubContext.LogInfo($"File saved successfully: {relativePath}");
// Notify other clients that file was saved (fire-and-forget)
_ = Clients.Others.SendAsync("FileSaved", relativePath, Context.UserIdentifier);
}
catch (UnauthorizedAccessException ex)
{
_consoleHubContext.LogError($"Failed to save file '{relativePath}': {ex.Message}");
throw;
}
catch (Exception ex)
{
_consoleHubContext.LogError($"Error saving file '{relativePath}': {ex.Message}");
throw;
}
}
/// <summary>
/// Creates a new file.
/// </summary>
public async Task CreateFile(string relativePath, string content)
{
try
{
await _fileManager.CreateFileAsync(relativePath, content, Context.ConnectionId);
_consoleHubContext.LogInfo($"File created successfully: {relativePath}");
// Notify other clients that file was created (fire-and-forget)
_ = Clients.Others.SendAsync("FileCreated", relativePath, Context.UserIdentifier);
}
catch (UnauthorizedAccessException ex)
{
_consoleHubContext.LogError($"Failed to create file '{relativePath}': {ex.Message}");
throw;
}
catch (IOException ex)
{
_consoleHubContext.LogWarning($"Failed to create file '{relativePath}': {ex.Message}");
throw;
}
catch (Exception ex)
{
_consoleHubContext.LogError($"Error creating file '{relativePath}': {ex.Message}");
throw;
}
}
/// <summary>
/// Creates a new folder.
/// </summary>
public Task CreateFolder(string relativePath)
{
try
{
_fileManager.CreateFolder(relativePath, Context.ConnectionId);
_consoleHubContext.LogInfo($"Folder created successfully: {relativePath}");
// Notify other clients that folder was created (fire-and-forget)
_ = Clients.Others.SendAsync("FolderCreated", relativePath, Context.UserIdentifier);
}
catch (UnauthorizedAccessException ex)
{
_consoleHubContext.LogError($"Failed to create folder '{relativePath}': {ex.Message}");
throw;
}
catch (IOException ex)
{
_consoleHubContext.LogWarning($"Failed to create folder '{relativePath}': {ex.Message}");
throw;
}
catch (Exception ex)
{
_consoleHubContext.LogError($"Error creating folder '{relativePath}': {ex.Message}");
throw;
}
return Task.CompletedTask;
}
/// <summary>
/// Deletes a file.
/// </summary>
public Task DeleteFile(string relativePath)
{
try
{
_fileManager.DeleteFile(relativePath, Context.ConnectionId);
_consoleHubContext.LogInfo($"File deleted successfully: {relativePath}");
// Notify other clients that file was deleted (fire-and-forget)
_ = Clients.Others.SendAsync("FileDeleted", relativePath, Context.UserIdentifier);
}
catch (UnauthorizedAccessException ex)
{
_consoleHubContext.LogError($"Failed to delete file '{relativePath}': {ex.Message}");
throw;
}
catch (FileNotFoundException ex)
{
_consoleHubContext.LogWarning($"Failed to delete file '{relativePath}': {ex.Message}");
throw;
}
catch (Exception ex)
{
_consoleHubContext.LogError($"Error deleting file '{relativePath}': {ex.Message}");
throw;
}
return Task.CompletedTask;
}
/// <summary>
/// Deletes a folder.
/// </summary>
public Task DeleteFolder(string relativePath)
{
try
{
_fileManager.DeleteFolder(relativePath, Context.ConnectionId);
_consoleHubContext.LogInfo($"Folder deleted successfully: {relativePath}");
// Notify other clients that folder was deleted (fire-and-forget)
_ = Clients.Others.SendAsync("FolderDeleted", relativePath, Context.UserIdentifier);
}
catch (UnauthorizedAccessException ex)
{
_consoleHubContext.LogError($"Failed to delete folder '{relativePath}': {ex.Message}");
throw;
}
catch (DirectoryNotFoundException ex)
{
_consoleHubContext.LogWarning($"Failed to delete folder '{relativePath}': {ex.Message}");
throw;
}
catch (Exception ex)
{
_consoleHubContext.LogError($"Error deleting folder '{relativePath}': {ex.Message}");
throw;
}
return Task.CompletedTask;
}
/// <summary>
/// Creates a backup of all scripts.
/// </summary>
public async Task<string> CreateBackup(string? backupName = null)
{
try
{
var backupFileName = await _fileManager.CreateBackupAsync(backupName);
_consoleHubContext.LogInfo($"Backup created successfully: {backupFileName}");
// Notify all clients that backup was created (fire-and-forget)
_ = Clients.All.SendAsync("BackupCreated", backupFileName, Context.UserIdentifier);
return backupFileName;
}
catch (Exception ex)
{
_consoleHubContext.LogError($"Error creating backup: {ex.Message}");
throw;
}
}
/// <summary>
/// Lists available backup files.
/// </summary>
public Task<ScriptBackupInfo[]> ListBackups()
{
var backups = _fileManager.ListBackups();
return Task.FromResult(backups);
}
/// <summary>
/// Restores scripts from a backup.
/// </summary>
public async Task RestoreBackup(string backupFileName, bool replaceExisting = true)
{
try
{
await _fileManager.RestoreBackupAsync(backupFileName, replaceExisting);
_consoleHubContext.LogInfo($"Backup restored successfully: {backupFileName}");
// Notify all clients that backup was restored (fire-and-forget)
_ = Clients.All.SendAsync("BackupRestored", backupFileName, Context.UserIdentifier);
}
catch (Exception ex)
{
_consoleHubContext.LogError($"Error restoring backup '{backupFileName}': {ex.Message}");
throw;
}
}
/// <summary>
/// Deletes a backup file.
/// </summary>
public Task DeleteBackup(string backupFileName)
{
try
{
_fileManager.DeleteBackup(backupFileName);
_consoleHubContext.LogInfo($"Backup deleted successfully: {backupFileName}");
// Notify all clients that backup was deleted (fire-and-forget)
_ = Clients.All.SendAsync("BackupDeleted", backupFileName, Context.UserIdentifier);
}
catch (Exception ex)
{
_consoleHubContext.LogError($"Error deleting backup '{backupFileName}': {ex.Message}");
throw;
}
return Task.CompletedTask;
}
}

View File

@@ -0,0 +1,166 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.SignalR;
using Microsoft.EntityFrameworkCore;
using RobotNet10.ScriptEngine.Data;
using RobotNet10.ScriptEngine.Shared;
using RobotNet10.Shared;
using System.Security.Claims;
using System.Text.Json;
namespace RobotNet10.ScriptEngine.Hubs;
/// <summary>
/// SignalR hub for InstanceMission management operations.
/// </summary>
[Authorize]
public class InstanceMissionHub : Hub
{
private readonly ScriptEngineDbContext _dbContext;
private readonly MissionManager _missionManager;
public InstanceMissionHub(ScriptEngineDbContext dbContext, MissionManager missionManager)
{
_dbContext = dbContext ?? throw new ArgumentNullException(nameof(dbContext));
_missionManager = missionManager ?? throw new ArgumentNullException(nameof(missionManager));
}
/// <summary>
/// Searches instance missions with pagination and text search.
/// Running missions are prioritized.
/// </summary>
public async Task<SearchResult<InstanceMissionDto>> SearchInstanceMissions(SearchRequest request)
{
var query = _dbContext.InstanceMissions.AsQueryable();
// Text search on MissionName
if (!string.IsNullOrWhiteSpace(request.TxtSearch))
{
var searchText = request.TxtSearch.Trim();
query = query.Where(m => m.MissionName.Contains(searchText));
}
// Get total count before pagination
var total = await query.CountAsync();
// Order by: running missions first (Running, Pausing, Resuming), then by CreatedAt descending
var orderedQuery = query
.OrderByDescending(m => m.State == ScriptMissionState.Running ||
m.State == ScriptMissionState.Pausing ||
m.State == ScriptMissionState.Resuming)
.ThenByDescending(m => m.CreatedAt);
// Apply pagination
var items = await orderedQuery
.Skip((request.Page - 1) * request.Size)
.Take(request.Size)
.Select(m => new InstanceMissionDto
{
Id = m.Id,
MissionName = m.MissionName,
Parameters = m.Parameters ?? "{}",
CreatedAt = m.CreatedAt,
State = m.State,
TotalScore = m.TotalScore,
Score = m.Score,
StoppedAt = m.StoppedAt,
Log = m.Log
})
.ToArrayAsync();
return new SearchResult<InstanceMissionDto>(total, request.Page, request.Size, items);
}
/// <summary>
/// Gets the log for a specific instance mission.
/// </summary>
public async Task<string?> GetInstanceMissionLog(Guid missionId)
{
var mission = await _dbContext.InstanceMissions.FindAsync([missionId]);
return mission?.Log;
}
/// <summary>
/// Cancels a running or paused mission.
/// </summary>
public Task<MessageResult> CancelMission(Guid missionId, string reason)
{
var mission = _missionManager.GetMission(missionId);
if (mission == null)
{
return Task.FromResult(new MessageResult(false, "Mission not found"));
}
try
{
if (mission.State != ScriptMissionState.Running &&
mission.State != ScriptMissionState.Paused &&
mission.State != ScriptMissionState.Pausing)
{
return Task.FromResult(new MessageResult(false, $"Mission is in {mission.State} state and cannot be canceled"));
}
mission.Cancel(reason);
return Task.FromResult(new MessageResult(true, $"Mission canceled{(string.IsNullOrWhiteSpace(reason) ? "" : $": {reason}")}"));
}
catch (Exception ex)
{
return Task.FromResult(new MessageResult(false, $"Failed to cancel mission: {ex.Message}"));
}
}
/// <summary>
/// Pauses a running mission.
/// </summary>
public Task<MessageResult> PauseMission(Guid missionId)
{
var mission = _missionManager.GetMission(missionId);
if (mission == null)
{
return Task.FromResult(new MessageResult(false, "Mission not found"));
}
try
{
if (mission.State != ScriptMissionState.Running)
{
return Task.FromResult(new MessageResult(false, $"Mission is in {mission.State} state and cannot be paused"));
}
mission.Pause();
return Task.FromResult(new MessageResult(true, "Mission paused"));
}
catch (Exception ex)
{
return Task.FromResult(new MessageResult(false, $"Failed to pause mission: {ex.Message}"));
}
}
/// <summary>
/// Resumes a paused mission.
/// </summary>
public Task<MessageResult> ResumeMission(Guid missionId)
{
var mission = _missionManager.GetMission(missionId);
if (mission == null)
{
return Task.FromResult(new MessageResult(false, "Mission not found"));
}
try
{
if (mission.State != ScriptMissionState.Paused)
{
return Task.FromResult(new MessageResult(false, $"Mission is in {mission.State} state and cannot be resumed"));
}
mission.Resume();
return Task.FromResult(new MessageResult(true, "Mission resumed"));
}
catch (Exception ex)
{
return Task.FromResult(new MessageResult(false, $"Failed to resume mission: {ex.Message}"));
}
}
}

View File

@@ -0,0 +1,227 @@
using Microsoft.AspNetCore.SignalR;
using RobotNet10.ScriptEngine.Shared;
using RobotNet10.Shared;
namespace RobotNet10.ScriptEngine.Hubs;
/// <summary>
/// SignalR hub for ScriptEngine management operations.
/// </summary>
/// <remarks>
/// Initializes a new instance of ScriptManagerHub.
/// </remarks>
public class ScriptManagerHub(ScriptEngine _scriptEngine) : Hub
{
/// <summary>
/// Gets the current state of the ScriptEngine.
/// </summary>
public Task<ScriptEngineState> GetState()
{
return Task.FromResult(_scriptEngine.State);
}
/// <summary>
/// Builds scripts from all files. Only allowed when state is Idle or BuildError.
/// </summary>
public Task<MessageResult> Build()
{
return Task.FromResult(_scriptEngine.Build());
}
/// <summary>
/// Starts the ScriptEngine. Only allowed when state is Ready.
/// </summary>
public Task<MessageResult> Start()
{
return Task.FromResult(_scriptEngine.Start());
}
/// <summary>
/// Stops the ScriptEngine. Only allowed when state is Running.
/// </summary>
public Task<MessageResult> Stop()
{
return Task.FromResult(_scriptEngine.Stop());
}
/// <summary>
/// Resets the ScriptEngine. Allowed from Idle, Ready, BuildError, Running, or Fault.
/// </summary>
public Task<MessageResult> Reset()
{
return Task.FromResult(_scriptEngine.Reset());
}
#region VariableManager Methods
/// <summary>
/// Gets all script variables.
/// </summary>
public Task<ScriptVariableDto[]> GetScriptVariables()
{
return Task.FromResult(_scriptEngine.VariableManager.GetVariables().ToArray());
}
/// <summary>
/// Finds specific script variables by names.
/// </summary>
public Task<ScriptVariableDto[]> FindScriptVariables(string[] names)
{
return Task.FromResult(_scriptEngine.VariableManager.GetVariables(names).ToArray());
}
/// <summary>
/// Sets the value of a script variable by name.
/// </summary>
public Task<MessageResult> SetValue(string name, string value)
{
return Task.FromResult(_scriptEngine.VariableManager.SetValue(name, value));
}
#endregion
#region TaskManager Methods
/// <summary>
/// Gets all script tasks.
/// </summary>
public Task<ScriptTaskDto[]> GetScriptTasks()
{
return Task.FromResult(_scriptEngine.TaskManager.GetScriptTasks());
}
/// <summary>
/// Finds specific script tasks by names.
/// </summary>
public Task<ScriptTaskDto[]> FindScriptTasks(string[] names)
{
return Task.FromResult(_scriptEngine.TaskManager.FindScriptTasks(names));
}
/// <summary>
/// Enables a task (resumes if paused, starts if stopped).
/// </summary>
public Task<MessageResult> EnableTask(string name)
{
return Task.FromResult(_scriptEngine.TaskManager.EnableTask(name));
}
/// <summary>
/// Disables a task (pauses if running).
/// </summary>
public Task<MessageResult> DisableTask(string name)
{
return Task.FromResult(_scriptEngine.TaskManager.DisableTask(name));
}
#endregion
#region MissionManager Methods
/// <summary>
/// Gets all script missions.
/// </summary>
public Task<ScriptMissionDto[]> GetScriptMissions()
{
return Task.FromResult(_scriptEngine.MissionManager.GetScriptMissions());
}
/// <summary>
/// Finds specific script missions by names.
/// </summary>
public Task<ScriptMissionDto[]> FindScriptMissions(string[] names)
{
return Task.FromResult(_scriptEngine.MissionManager.FindScriptMissions(names));
}
/// <summary>
/// Creates a mission instance with parameters provided as a dictionary.
/// </summary>
/// <param name="name">The name of the mission model.</param>
/// <param name="args">Dictionary of parameter values keyed by parameter name (as strings).</param>
/// <returns>MessageResult containing the mission ID if successful.</returns>
public async Task<MessageResult<Guid>> CreateMission(string name, IDictionary<string, string> args)
{
if (string.IsNullOrWhiteSpace(name))
return new MessageResult<Guid>(false, default, "Mission name cannot be null or empty");
if (args == null)
return new MessageResult<Guid>(false, default, "Parameters cannot be null");
// Convert string dictionary to object dictionary
var missionModel = _scriptEngine.MissionManager.GetMissionModel(name);
if (missionModel == null)
return new MessageResult<Guid>(false, default, $"Mission model '{name}' not found");
var parameters = new Dictionary<string, object?>();
foreach (var paramModel in missionModel.Parameters)
{
// Skip CancellationToken parameters
if (paramModel.Type == typeof(CancellationToken))
continue;
if (args.TryGetValue(paramModel.Name, out var stringValue))
{
try
{
object? convertedValue = null;
if (!string.IsNullOrEmpty(stringValue) && stringValue != "null")
{
if (paramModel.Type == typeof(string))
{
convertedValue = stringValue;
}
else if (paramModel.Type.IsEnum)
{
convertedValue = Enum.Parse(paramModel.Type, stringValue, true);
}
else
{
convertedValue = Convert.ChangeType(stringValue, paramModel.Type);
}
}
parameters[paramModel.Name] = convertedValue;
}
catch (Exception ex)
{
var errorMessage = $"Failed to convert parameter '{paramModel.Name}' value '{stringValue}' to type '{paramModel.Type.Name}': {ex.Message}";
// Log error via ConsoleHubContext if available
// Note: We don't have direct access to ConsoleHubContext here, but error is returned to client
return new MessageResult<Guid>(false, default, errorMessage);
}
}
else
{
// Use default value if not provided
parameters[paramModel.Name] = paramModel.DefaultValue;
}
}
return await Task.FromResult(_scriptEngine.MissionManager.CreateMission(name, parameters));
}
/// <summary>
/// Cancels a running or paused mission.
/// </summary>
/// <param name="id">The ID of the mission instance to cancel.</param>
/// <param name="reason">The reason for cancellation.</param>
/// <returns>True if the mission was canceled successfully, false otherwise.</returns>
public Task<bool> CancelMission(Guid id, string reason)
{
var mission = _scriptEngine.MissionManager.GetMission(id);
if (mission == null)
return Task.FromResult(false);
try
{
mission.Cancel(reason);
return Task.FromResult(true);
}
catch
{
return Task.FromResult(false);
}
}
#endregion
}

View File

@@ -0,0 +1,103 @@
using RobotNet10.Script.IO;
using System.Net.Sockets;
namespace RobotNet10.ScriptEngine.IO;
/// <summary>
/// Implementation of CC-Link IE connection.
/// Note: This is a basic implementation. Full CC-Link IE support may require additional libraries.
/// </summary>
public class CcLinkIeConnection : ICcLinkIeConnection
{
private TcpClient? _tcpClient;
private NetworkStream? _stream;
private bool _disposed;
public string IpAddress { get; }
public int StationNumber { get; }
public bool IsConnected { get; private set; }
public CcLinkIeConnection(string ipAddress, int stationNumber = 1)
{
IpAddress = ipAddress;
StationNumber = stationNumber;
IsConnected = false;
}
public async Task ConnectAsync()
{
if (IsConnected)
return;
try
{
_tcpClient = new TcpClient();
await _tcpClient.ConnectAsync(IpAddress, 5007); // Standard CC-Link IE port
_stream = _tcpClient.GetStream();
IsConnected = true;
// TODO: Implement CC-Link IE handshake protocol
// This requires implementing the CC-Link IE protocol stack
}
catch
{
Disconnect();
throw;
}
}
public Task DisconnectAsync()
{
Disconnect();
return Task.CompletedTask;
}
private void Disconnect()
{
IsConnected = false;
_stream?.Close();
_stream = null;
_tcpClient?.Close();
_tcpClient?.Dispose();
_tcpClient = null;
}
public async Task<ushort[]> ReadAsync(int address, int length)
{
EnsureConnected();
// TODO: Implement CC-Link IE read operation
// This requires implementing the CC-Link IE protocol
await Task.CompletedTask;
throw new NotImplementedException("CC-Link IE read operation is not yet fully implemented. Full CC-Link IE support requires additional protocol implementation.");
}
public async Task WriteAsync(int address, ushort[] data)
{
EnsureConnected();
// TODO: Implement CC-Link IE write operation
// This requires implementing the CC-Link IE protocol
await Task.CompletedTask;
throw new NotImplementedException("CC-Link IE write operation is not yet fully implemented. Full CC-Link IE support requires additional protocol implementation.");
}
private void EnsureConnected()
{
if (!IsConnected || _stream == null)
{
throw new InvalidOperationException("CC-Link IE connection is not connected. Call ConnectAsync() first.");
}
}
public void Dispose()
{
if (!_disposed)
{
Disconnect();
_disposed = true;
}
GC.SuppressFinalize(this);
}
}

View File

@@ -0,0 +1,123 @@
using System.Net.Http.Headers;
using RobotNet10.Script.IO;
namespace RobotNet10.ScriptEngine.IO;
/// <summary>
/// Implementation of HTTP connection using HttpClient.
/// </summary>
public class HttpConnection : IHttpConnection
{
private readonly HttpClient _httpClient;
private bool _disposed;
public string BaseUrl { get; }
public bool IsConnected { get; private set; }
public HttpConnection(string baseUrl, TimeSpan? timeout = null)
{
BaseUrl = baseUrl.TrimEnd('/');
_httpClient = new HttpClient
{
BaseAddress = new Uri(BaseUrl),
Timeout = timeout ?? TimeSpan.FromSeconds(30)
};
IsConnected = false;
}
public async Task ConnectAsync()
{
// HTTP doesn't require explicit connection, but we can verify connectivity
try
{
var response = await _httpClient.GetAsync("/");
IsConnected = true;
}
catch
{
IsConnected = false;
throw;
}
}
public Task DisconnectAsync()
{
IsConnected = false;
return Task.CompletedTask;
}
public async Task<string> GetAsync(string path, Dictionary<string, string>? headers = null)
{
EnsureConnected();
using var request = new HttpRequestMessage(HttpMethod.Get, path);
AddHeaders(request, headers);
var response = await _httpClient.SendAsync(request);
response.EnsureSuccessStatusCode();
return await response.Content.ReadAsStringAsync();
}
public async Task<string> PostAsync(string path, string content, string contentType = "application/json", Dictionary<string, string>? headers = null)
{
EnsureConnected();
using var request = new HttpRequestMessage(HttpMethod.Post, path);
request.Content = new StringContent(content);
request.Content.Headers.ContentType = MediaTypeHeaderValue.Parse(contentType);
AddHeaders(request, headers);
var response = await _httpClient.SendAsync(request);
response.EnsureSuccessStatusCode();
return await response.Content.ReadAsStringAsync();
}
public async Task<string> PutAsync(string path, string content, string contentType = "application/json", Dictionary<string, string>? headers = null)
{
EnsureConnected();
using var request = new HttpRequestMessage(HttpMethod.Put, path);
request.Content = new StringContent(content);
request.Content.Headers.ContentType = MediaTypeHeaderValue.Parse(contentType);
AddHeaders(request, headers);
var response = await _httpClient.SendAsync(request);
response.EnsureSuccessStatusCode();
return await response.Content.ReadAsStringAsync();
}
public async Task<string> DeleteAsync(string path, Dictionary<string, string>? headers = null)
{
EnsureConnected();
using var request = new HttpRequestMessage(HttpMethod.Delete, path);
AddHeaders(request, headers);
var response = await _httpClient.SendAsync(request);
response.EnsureSuccessStatusCode();
return await response.Content.ReadAsStringAsync();
}
private static void AddHeaders(HttpRequestMessage request, Dictionary<string, string>? headers)
{
if (headers != null)
{
foreach (var header in headers)
{
request.Headers.Add(header.Key, header.Value);
}
}
}
private void EnsureConnected()
{
if (!IsConnected)
{
throw new InvalidOperationException("HTTP connection is not connected. Call ConnectAsync() first.");
}
}
public void Dispose()
{
if (!_disposed)
{
_httpClient?.Dispose();
IsConnected = false;
_disposed = true;
}
GC.SuppressFinalize(this);
}
}

View File

@@ -0,0 +1,134 @@
using Modbus.Device;
using RobotNet10.Script.IO;
using System.Net.Sockets;
namespace RobotNet10.ScriptEngine.IO;
/// <summary>
/// Implementation of ModbusTCP connection using NModbus4 library.
/// </summary>
public class ModbusTcpConnection(string ipAddress, int port = 502, byte slaveId = 1, int connectTimeoutMs = 5000) : IModbusTcpConnection
{
private TcpClient? _tcpClient;
private ModbusMaster? _modbusMaster;
private bool _disposed;
public string IpAddress { get; } = ipAddress;
public int Port { get; } = port;
public byte SlaveId { get; } = slaveId;
public int ConnectTimeoutMs { get; } = connectTimeoutMs;
public bool IsConnected { get; private set; } = false;
public async Task ConnectAsync()
{
if (IsConnected)
return;
try
{
_tcpClient = new TcpClient();
// Sử dụng ConnectTimeout
using (var cts = new CancellationTokenSource(TimeSpan.FromMilliseconds(ConnectTimeoutMs)))
{
await _tcpClient.ConnectAsync(IpAddress, Port).WaitAsync(cts.Token);
}
_modbusMaster = ModbusIpMaster.CreateIp(_tcpClient);
IsConnected = true;
}
catch (OperationCanceledException)
{
Disconnect();
throw new TimeoutException($"Connection to {IpAddress}:{Port} timed out after {ConnectTimeoutMs}ms");
}
catch
{
Disconnect();
throw;
}
}
public Task DisconnectAsync()
{
Disconnect();
return Task.CompletedTask;
}
private void Disconnect()
{
IsConnected = false;
_modbusMaster?.Dispose();
_modbusMaster = null;
_tcpClient?.Close();
_tcpClient?.Dispose();
_tcpClient = null;
}
public async Task<ushort[]> ReadHoldingRegistersAsync(ushort startAddress, ushort numberOfPoints)
{
EnsureConnected();
return await Task.Run(() => _modbusMaster!.ReadHoldingRegisters(SlaveId, startAddress, numberOfPoints));
}
public async Task<ushort[]> ReadInputRegistersAsync(ushort startAddress, ushort numberOfPoints)
{
EnsureConnected();
return await Task.Run(() => _modbusMaster!.ReadInputRegisters(SlaveId, startAddress, numberOfPoints));
}
public async Task<bool[]> ReadCoilsAsync(ushort startAddress, ushort numberOfPoints)
{
EnsureConnected();
return await Task.Run(() => _modbusMaster!.ReadCoils(SlaveId, startAddress, numberOfPoints));
}
public async Task<bool[]> ReadDiscreteInputsAsync(ushort startAddress, ushort numberOfPoints)
{
EnsureConnected();
return await Task.Run(() => _modbusMaster!.ReadInputs(SlaveId, startAddress, numberOfPoints));
}
public async Task WriteSingleCoilAsync(ushort coilAddress, bool value)
{
EnsureConnected();
await Task.Run(() => _modbusMaster!.WriteSingleCoil(SlaveId, coilAddress, value));
}
public async Task WriteMultipleCoilsAsync(ushort startAddress, bool[] values)
{
EnsureConnected();
await Task.Run(() => _modbusMaster!.WriteMultipleCoils(SlaveId, startAddress, values));
}
public async Task WriteSingleRegisterAsync(ushort registerAddress, ushort value)
{
EnsureConnected();
await Task.Run(() => _modbusMaster!.WriteSingleRegister(SlaveId, registerAddress, value));
}
public async Task WriteMultipleRegistersAsync(ushort startAddress, ushort[] values)
{
EnsureConnected();
await Task.Run(() => _modbusMaster!.WriteMultipleRegisters(SlaveId, startAddress, values));
}
private void EnsureConnected()
{
if (!IsConnected || _modbusMaster == null)
{
throw new InvalidOperationException("ModbusTCP connection is not connected. Call ConnectAsync() first.");
}
}
public void Dispose()
{
if (!_disposed)
{
Disconnect();
_disposed = true;
}
GC.SuppressFinalize(this);
}
}

View File

@@ -0,0 +1,312 @@
using System.Diagnostics;
using Microsoft.Extensions.Logging;
using Opc.Ua;
using Opc.Ua.Client;
using RobotNet10.Script.IO;
namespace RobotNet10.ScriptEngine.IO;
/// <summary>
/// Implementation of OPC UA connection using OPCFoundation.NetStandard.Opc.Ua library.
/// </summary>
public class OpcUaConnection : IOpcUaConnection
{
private Session? _session;
private SessionReconnectHandler? _reconnectHandler;
private bool _disposed;
public string EndpointUrl { get; }
public bool IsConnected { get; private set; }
public OpcUaConnection(string endpointUrl)
{
EndpointUrl = endpointUrl;
IsConnected = false;
}
public async Task ConnectAsync()
{
await ConnectAsync(string.Empty, string.Empty);
}
public async Task ConnectAsync(string username, string password)
{
if (IsConnected)
return;
try
{
var applicationConfiguration = new ApplicationConfiguration
{
ApplicationName = "RobotNet10 ScriptEngine",
ApplicationUri = Utils.Format(@"urn:{0}:ScriptEngine", System.Net.Dns.GetHostName()),
ApplicationType = ApplicationType.Client,
SecurityConfiguration = new SecurityConfiguration
{
ApplicationCertificate = new CertificateIdentifier { StoreType = @"Directory", StorePath = @"%CommonApplicationData%\OPC Foundation\CertificateStores\MachineDefault" },
TrustedIssuerCertificates = new CertificateTrustList { StoreType = @"Directory", StorePath = @"%CommonApplicationData%\OPC Foundation\CertificateStores\UA Certificate Authorities" },
TrustedPeerCertificates = new CertificateTrustList { StoreType = @"Directory", StorePath = @"%CommonApplicationData%\OPC Foundation\CertificateStores\UA Applications" },
RejectedCertificateStore = new CertificateTrustList { StoreType = @"Directory", StorePath = @"%CommonApplicationData%\OPC Foundation\CertificateStores\RejectedCertificates" },
AutoAcceptUntrustedCertificates = true,
RejectSHA1SignedCertificates = false
},
TransportConfigurations = new TransportConfigurationCollection(),
ClientConfiguration = new ClientConfiguration
{
DefaultSessionTimeout = 60000
}
};
await applicationConfiguration.ValidateAsync(ApplicationType.Client);
// Discover endpoints first using new API
var endpointUrl = new Uri(EndpointUrl);
var telemetryContext = new SimpleTelemetryContext();
var discoveryClient = await DiscoveryClient.CreateAsync(applicationConfiguration, endpointUrl, DiagnosticsMasks.All, CancellationToken.None);
var endpoints = await discoveryClient.GetEndpointsAsync(null, CancellationToken.None);
await discoveryClient.CloseAsync(CancellationToken.None);
// Select endpoint - use the first available endpoint or find best match
if (endpoints == null || endpoints.Count == 0)
{
throw new InvalidOperationException($"No endpoints found for OPC UA server at {EndpointUrl}");
}
// Try to find a secure endpoint first, otherwise use the first one
var endpointDescription = endpoints.FirstOrDefault(e => e.SecurityMode != MessageSecurityMode.None) ?? endpoints[0];
var endpointConfiguration = EndpointConfiguration.Create(applicationConfiguration);
var configuredEndpoint = new ConfiguredEndpoint(null, endpointDescription, endpointConfiguration);
UserIdentity? userIdentity = null;
if (!string.IsNullOrEmpty(username))
{
var passwordBytes = string.IsNullOrEmpty(password) ? Array.Empty<byte>() : System.Text.Encoding.UTF8.GetBytes(password);
userIdentity = new UserIdentity(username, passwordBytes);
}
// Use ISessionFactory.CreateAsync instead of Session.CreateAsync
ISessionFactory sessionFactory = new DefaultSessionFactory(telemetryContext);
var session = await sessionFactory.CreateAsync(
applicationConfiguration,
configuredEndpoint,
updateBeforeConnect: false,
checkDomain: false,
"RobotNet10 ScriptEngine Session",
60000,
userIdentity,
preferredLocales: null,
CancellationToken.None);
// Cast ISession to Session
_session = session as Session ?? throw new InvalidOperationException("Failed to create OPC UA session");
_session.KeepAlive += (ISession session, KeepAliveEventArgs e) =>
{
if (e.CurrentState != ServerState.Unknown)
{
return;
}
if (_reconnectHandler != null && session is Session sessionImpl)
{
_reconnectHandler.BeginReconnect(sessionImpl, 5000, (sender, args) => { });
}
};
_reconnectHandler = new SessionReconnectHandler(telemetryContext, false, 5000);
_reconnectHandler.BeginReconnect(_session, 5000, (sender, e) => { });
IsConnected = true;
}
catch
{
Disconnect();
throw;
}
}
public async Task DisconnectAsync()
{
IsConnected = false;
_reconnectHandler?.Dispose();
_reconnectHandler = null;
if (_session != null)
{
try
{
await _session.CloseAsync(CancellationToken.None);
}
catch
{
// Ignore errors during close
}
_session.Dispose();
}
_session = null;
}
private void Disconnect()
{
// Synchronous wrapper for async disconnect
DisconnectAsync().GetAwaiter().GetResult();
}
public async Task<object?> ReadNodeAsync(string nodeId)
{
EnsureConnected();
var node = new NodeId(nodeId);
var readValueId = new ReadValueId
{
NodeId = node,
AttributeId = Attributes.Value
};
var readValueIdCollection = new ReadValueIdCollection { readValueId };
var response = await _session!.ReadAsync(null, 0, TimestampsToReturn.Neither, readValueIdCollection, CancellationToken.None);
if (StatusCode.IsGood(response.Results[0].StatusCode))
{
return response.Results[0].Value;
}
throw new Exception($"Failed to read node {nodeId}: {response.Results[0].StatusCode}");
}
public async Task<Dictionary<string, object?>> ReadNodesAsync(string[] nodeIds)
{
EnsureConnected();
var readValueIdCollection = new ReadValueIdCollection();
foreach (var nodeId in nodeIds)
{
readValueIdCollection.Add(new ReadValueId
{
NodeId = new NodeId(nodeId),
AttributeId = Attributes.Value
});
}
var response = await _session!.ReadAsync(null, 0, TimestampsToReturn.Neither, readValueIdCollection, CancellationToken.None);
var result = new Dictionary<string, object?>();
for (int i = 0; i < nodeIds.Length; i++)
{
if (StatusCode.IsGood(response.Results[i].StatusCode))
{
result[nodeIds[i]] = response.Results[i].Value;
}
else
{
result[nodeIds[i]] = null;
}
}
return result;
}
public async Task WriteNodeAsync(string nodeId, object value)
{
EnsureConnected();
var writeValue = new WriteValue
{
NodeId = new NodeId(nodeId),
AttributeId = Attributes.Value,
Value = new DataValue(new Variant(value))
};
var writeValueCollection = new WriteValueCollection { writeValue };
var response = await _session!.WriteAsync(null, writeValueCollection, CancellationToken.None);
if (!StatusCode.IsGood(response.Results[0]))
{
throw new Exception($"Failed to write node {nodeId}: {response.Results[0]}");
}
}
public async Task WriteNodesAsync(Dictionary<string, object> values)
{
EnsureConnected();
var writeValueCollection = new WriteValueCollection();
foreach (var kvp in values)
{
writeValueCollection.Add(new WriteValue
{
NodeId = new NodeId(kvp.Key),
AttributeId = Attributes.Value,
Value = new DataValue(new Variant(kvp.Value))
});
}
var response = await _session!.WriteAsync(null, writeValueCollection, CancellationToken.None);
for (int i = 0; i < response.Results.Count; i++)
{
if (!StatusCode.IsGood(response.Results[i]))
{
var nodeId = values.Keys.ElementAt(i);
throw new Exception($"Failed to write node {nodeId}: {response.Results[i]}");
}
}
}
public async Task<string[]> BrowseNodesAsync(string? nodeId = null)
{
EnsureConnected();
var node = nodeId == null ? ObjectIds.ObjectsFolder : new NodeId(nodeId);
var nodesToBrowse = new BrowseDescriptionCollection
{
new BrowseDescription
{
NodeId = node,
BrowseDirection = BrowseDirection.Forward,
ReferenceTypeId = ReferenceTypeIds.HierarchicalReferences,
IncludeSubtypes = true,
NodeClassMask = 0,
ResultMask = (uint)BrowseResultMask.All
}
};
var response = await _session!.BrowseAsync(null, null, 0, nodesToBrowse, CancellationToken.None);
if (response.Results != null && response.Results.Count > 0 && response.Results[0].References != null)
{
return response.Results[0].References.Select(rd => rd.NodeId.ToString()).ToArray();
}
return Array.Empty<string>();
}
private void EnsureConnected()
{
if (!IsConnected || _session == null)
{
throw new InvalidOperationException("OPC UA connection is not connected. Call ConnectAsync() first.");
}
}
public void Dispose()
{
if (!_disposed)
{
Disconnect();
_disposed = true;
}
GC.SuppressFinalize(this);
}
}
/// <summary>
/// Simple telemetry context implementation for OPC UA
/// </summary>
internal class SimpleTelemetryContext : ITelemetryContext
{
public ILoggerFactory LoggerFactory => Microsoft.Extensions.Logging.Abstractions.NullLoggerFactory.Instance;
public ActivitySource ActivitySource => new ActivitySource("RobotNet10.ScriptEngine.OpcUa");
public System.Diagnostics.Metrics.Meter CreateMeter() => new System.Diagnostics.Metrics.Meter("RobotNet10.ScriptEngine.OpcUa");
}

View File

@@ -0,0 +1,105 @@
using RobotNet10.Script.IO;
using System.Net.Sockets;
namespace RobotNet10.ScriptEngine.IO;
/// <summary>
/// Implementation of ProfiNet connection.
/// Note: This is a basic implementation. Full ProfiNet support may require additional libraries.
/// </summary>
public class ProfiNetConnection : IProfiNetConnection
{
private TcpClient? _tcpClient;
private NetworkStream? _stream;
private bool _disposed;
public string IpAddress { get; }
public int Slot { get; }
public int Subslot { get; }
public bool IsConnected { get; private set; }
public ProfiNetConnection(string ipAddress, int slot = 1, int subslot = 1)
{
IpAddress = ipAddress;
Slot = slot;
Subslot = subslot;
IsConnected = false;
}
public async Task ConnectAsync()
{
if (IsConnected)
return;
try
{
_tcpClient = new TcpClient();
await _tcpClient.ConnectAsync(IpAddress, 34964); // Standard ProfiNet port
_stream = _tcpClient.GetStream();
IsConnected = true;
// TODO: Implement ProfiNet DCP (Discovery and Configuration Protocol) handshake
// This requires implementing the ProfiNet protocol stack
}
catch
{
Disconnect();
throw;
}
}
public Task DisconnectAsync()
{
Disconnect();
return Task.CompletedTask;
}
private void Disconnect()
{
IsConnected = false;
_stream?.Close();
_stream = null;
_tcpClient?.Close();
_tcpClient?.Dispose();
_tcpClient = null;
}
public async Task<byte[]> ReadAsync(int index, int length)
{
EnsureConnected();
// TODO: Implement ProfiNet read operation
// This requires implementing the ProfiNet IO data exchange protocol
await Task.CompletedTask;
throw new NotImplementedException("ProfiNet read operation is not yet fully implemented. Full ProfiNet support requires additional protocol implementation.");
}
public async Task WriteAsync(int index, byte[] data)
{
EnsureConnected();
// TODO: Implement ProfiNet write operation
// This requires implementing the ProfiNet IO data exchange protocol
await Task.CompletedTask;
throw new NotImplementedException("ProfiNet write operation is not yet fully implemented. Full ProfiNet support requires additional protocol implementation.");
}
private void EnsureConnected()
{
if (!IsConnected || _stream == null)
{
throw new InvalidOperationException("ProfiNet connection is not connected. Call ConnectAsync() first.");
}
}
public void Dispose()
{
if (!_disposed)
{
Disconnect();
_disposed = true;
}
GC.SuppressFinalize(this);
}
}

View File

@@ -0,0 +1,923 @@
using Appccelerate.StateMachine;
using Appccelerate.StateMachine.Machine;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using RobotNet10.Script;
using RobotNet10.ScriptEngine.Helpers;
using RobotNet10.ScriptEngine.HubContexts;
using RobotNet10.ScriptEngine.Data;
using RobotNet10.ScriptEngine.Models;
using RobotNet10.ScriptEngine.Shared;
using RobotNet10.Shared;
using System.Collections.Concurrent;
using System.Diagnostics;
using System.Text.Json;
namespace RobotNet10.ScriptEngine;
/// <summary>
/// State enum for MissionManager.
/// </summary>
public enum MissionManagerState
{
Idle = 0,
Running,
Stopping,
}
/// <summary>
/// Triggers for MissionManager state machine.
/// </summary>
public enum MissionManagerTrigger
{
Start,
Stop,
StoppingCompleted,
}
/// <summary>
/// Manages script missions with state machine support.
/// </summary>
public class MissionManager : IDisposable
{
private readonly PassiveStateMachine<MissionManagerState, MissionManagerTrigger> _stateMachine;
private readonly ConcurrentDictionary<string, ScriptMissionModel> _missionModels = new();
private readonly ConcurrentQueue<ScriptMission> _idleMissions = new();
private readonly ConcurrentQueue<ScriptMission> _runningMissions = new();
private readonly ConcurrentDictionary<Guid, ScriptMission> _allMissions = new();
private readonly Lock _lockObject = new();
private readonly Lock _stateLockObject = new();
private readonly Lock _queueLockObject = new();
private readonly IServiceScopeFactory _scopeFactory;
private readonly VariableManager _variableManager;
private readonly IScriptEngineResource _scriptResource;
private readonly ILogger<ScriptEngineGlobals> _logger;
private readonly ConsoleHubContext _consoleHubContext;
private readonly IConfiguration _configuration;
private Task? _runningHandlerTask;
private CancellationTokenSource? _runningHandlerCts;
private readonly ManualResetEventSlim _stoppedWaitHandle = new(false);
private bool _disposed;
private MissionManagerState _currentState = MissionManagerState.Idle;
/// <summary>
/// Gets the current state of the MissionManager.
/// </summary>
public MissionManagerState State => _currentState;
/// <summary>
/// Gets all mission models.
/// </summary>
public IReadOnlyDictionary<string, ScriptMissionModel> MissionModels => _missionModels;
/// <summary>
/// Initializes a new instance of MissionManager.
/// </summary>
public MissionManager(
IServiceScopeFactory scopeFactory,
VariableManager variableManager,
IScriptEngineResource scriptResource,
ILogger<ScriptEngineGlobals> logger,
ConsoleHubContext consoleHubContext,
IConfiguration configuration)
{
_scopeFactory = scopeFactory ?? throw new ArgumentNullException(nameof(scopeFactory));
_variableManager = variableManager ?? throw new ArgumentNullException(nameof(variableManager));
_scriptResource = scriptResource ?? throw new ArgumentNullException(nameof(scriptResource));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_consoleHubContext = consoleHubContext ?? throw new ArgumentNullException(nameof(consoleHubContext));
_configuration = configuration ?? throw new ArgumentNullException(nameof(configuration));
var builder = new StateMachineDefinitionBuilder<MissionManagerState, MissionManagerTrigger>();
// Idle state - can add/remove mission models
builder.In(MissionManagerState.Idle)
.ExecuteOnEntry(() => { lock (_stateLockObject) { _currentState = MissionManagerState.Idle; } })
.On(MissionManagerTrigger.Start)
.Goto(MissionManagerState.Running)
.Execute(() => { lock (_stateLockObject) { _currentState = MissionManagerState.Running; } OnEnterRunning(); });
// Running state - can create missions
builder.In(MissionManagerState.Running)
.ExecuteOnEntry(() => { lock (_stateLockObject) { _currentState = MissionManagerState.Running; } })
.On(MissionManagerTrigger.Stop)
.Goto(MissionManagerState.Stopping)
.Execute(() => { lock (_stateLockObject) { _currentState = MissionManagerState.Stopping; } OnEnterStopping(); });
// Stopping state - waiting for all missions to complete
builder.In(MissionManagerState.Stopping)
.ExecuteOnEntry(() => { lock (_stateLockObject) { _currentState = MissionManagerState.Stopping; } })
.On(MissionManagerTrigger.StoppingCompleted)
.Goto(MissionManagerState.Idle)
.Execute(() => { lock (_stateLockObject) { _currentState = MissionManagerState.Idle; } });
_stateMachine = builder
.WithInitialState(MissionManagerState.Idle)
.Build()
.CreatePassiveStateMachine();
_stateMachine.Start();
}
private void OnEnterRunning()
{
// Start running handler thread
_runningHandlerCts = new CancellationTokenSource();
_stoppedWaitHandle.Reset();
// Use standard thread pool
_runningHandlerTask = Task.Run(() => RunningHandlerAsync(_runningHandlerCts.Token));
// Create missions for models with AutoStart == true
lock (_lockObject)
{
foreach (var model in _missionModels.Values)
{
if (model.AutoStart)
{
try
{
// Validate auto-start mission parameters: must be empty or have exactly one CancellationToken parameter
var paramList = model.Parameters.ToList();
if (paramList.Count > 1)
{
var message = $"Mission '{model.Name}' has AutoStart=true but has {paramList.Count} parameters. Auto-start missions must have 0 or 1 parameter (CancellationToken). Skipping auto-start.";
_logger.LogWarning(message);
_consoleHubContext.LogWarning(message);
continue;
}
if (paramList.Count == 1)
{
var param = paramList[0];
if (param.Type != typeof(CancellationToken))
{
var message = $"Mission '{model.Name}' has AutoStart=true but parameter '{param.Name}' is not CancellationToken. Auto-start missions must have 0 or 1 CancellationToken parameter. Skipping auto-start.";
_logger.LogWarning(message);
_consoleHubContext.LogWarning(message);
continue;
}
}
// Generate mission ID first
var missionId = Guid.NewGuid();
// Create mission instance with default parameters
var parameters = model.Parameters.Select(p =>
{
object? value = p.Type == typeof(CancellationToken)
? CancellationToken.None
: p.DefaultValue;
return new ScriptMissionParameterModel(p.Name, p.Type, value);
});
var result = CreateMissionInternal(model.Name, parameters, missionId);
if (!result.IsSuccess)
{
// Log error but continue creating other missions
_logger.LogError($"Failed to auto-start mission '{model.Name}': {result.Message}");
_consoleHubContext.LogError($"Failed to auto-start mission '{model.Name}': {result.Message}");
}
}
catch (Exception ex)
{
// Log error but continue creating other missions
_logger.LogError(ex, $"Failed to auto-start mission '{model.Name}'");
_consoleHubContext.LogError($"Failed to auto-start mission '{model.Name}': {ex.Message}");
}
}
}
}
}
private void OnEnterStopping()
{
// Fire and forget async operation with proper error handling
_ = Task.Run(async () =>
{
try
{
// Stop running handler
_runningHandlerCts?.Cancel();
// Wait for running handler to complete (with timeout)
if (_runningHandlerTask != null)
{
try
{
await _runningHandlerTask.WaitAsync(TimeSpan.FromSeconds(10));
}
catch (TimeoutException)
{
_logger.LogWarning("MissionManager running handler task did not complete within timeout. Proceeding anyway.");
_consoleHubContext.LogWarning("MissionManager running handler task did not complete within timeout. Proceeding anyway.");
}
catch (Exception ex)
{
_logger.LogError(ex, "Error waiting for running handler task to complete");
}
}
// Wait for stopped signal (with timeout)
// If timeout, check if missions are actually executing
if (!_stoppedWaitHandle.Wait(TimeSpan.FromSeconds(30)))
{
// Check if there are actually running missions
var runningMissions = GetAllMissions().Where(m => m.IsExecuting).ToList();
if (runningMissions.Count > 0)
{
_logger.LogWarning($"Timeout waiting for {runningMissions.Count} mission(s) to stop. ScriptRunner may be blocking. Proceeding anyway.");
_consoleHubContext.LogWarning($"Timeout waiting for {runningMissions.Count} mission(s) to stop. ScriptRunner may be blocking. Proceeding anyway.");
}
else
{
_logger.LogInformation("All missions stopped. Proceeding with state transition.");
_consoleHubContext.LogInfo("All missions stopped. Proceeding with state transition.");
}
}
// Fire StoppingCompleted trigger (always proceed)
_stateMachine.Fire(MissionManagerTrigger.StoppingCompleted);
}
catch (Exception ex)
{
_logger.LogError(ex, "Unexpected error during MissionManager stopping");
_consoleHubContext.LogError($"MissionManager stopping error: {ex.Message}");
// Ensure WaitHandle is set even on error to prevent deadlock
try
{
_stoppedWaitHandle.Set();
}
catch { /* Ignore */ }
// Fire trigger to proceed
_stateMachine.Fire(MissionManagerTrigger.StoppingCompleted);
}
});
}
private async Task RunningHandlerAsync(CancellationToken cancellationToken)
{
var stopwatch = Stopwatch.StartNew();
int elapsed;
int remaining;
int interval = 1000;
int processTime = (int)(interval * 0.8);
int count;
_stoppedWaitHandle.Reset();
while (!cancellationToken.IsCancellationRequested)
{
stopwatch.Restart();
using (var scope = _scopeFactory.CreateScope())
{
var dbContext = scope.ServiceProvider.GetRequiredService<ScriptEngineDbContext>();
// Process idle queue: transition to running or complete immediately
count = _idleMissions.Count;
for (int i = 0; i < count; i++)
{
if (!_idleMissions.TryDequeue(out var mission)) break;
// Find corresponding database record
var dbMission = await dbContext.InstanceMissions.FindAsync([mission.Id], cancellationToken);
if (dbMission == null)
{
mission.Dispose();
_allMissions.TryRemove(mission.Id, out _);
continue; // Skip if mission not found in database
}
if (mission.State == ScriptMissionState.Idle)
{
// Start Mission and move to running queue
mission.Start();
_runningMissions.Enqueue(mission);
dbMission.State = mission.State;
}
else if (mission.State == ScriptMissionState.Completed
|| mission.State == ScriptMissionState.Canceled
|| mission.State == ScriptMissionState.Error)
{
// Mission completed/canceled/errored before running
dbMission.State = mission.State;
dbMission.Log += $"{Environment.NewLine}{mission.GetLog()}";
dbMission.Score = mission.CurrentScore;
dbMission.StoppedAt = DateTime.UtcNow;
mission.Dispose();
_allMissions.TryRemove(mission.Id, out _);
}
else
{
// Invalid state
dbMission.Log += $"{Environment.NewLine}{mission.GetLog()}{Environment.NewLine}{DateTime.UtcNow:O}: Mission is not in idle state. [{mission.State}]";
dbMission.State = ScriptMissionState.Error;
mission.Dispose();
_allMissions.TryRemove(mission.Id, out _);
}
}
// Process running queue: check completion or keep
count = _runningMissions.Count;
for (int i = 0; i < count; i++)
{
if (!_runningMissions.TryDequeue(out var mission)) break;
// Find corresponding database record
var dbMission = await dbContext.InstanceMissions.FindAsync([mission.Id], cancellationToken);
if (dbMission == null)
{
mission.Dispose();
_allMissions.TryRemove(mission.Id, out _);
continue; // Skip if mission not found in database
}
if (mission.State == ScriptMissionState.Completed
|| mission.State == ScriptMissionState.Canceled
|| mission.State == ScriptMissionState.Error)
{
// Mission completed - save results to database
dbMission.State = mission.State;
dbMission.Log += $"{Environment.NewLine}{mission.GetLog()}";
dbMission.Score = mission.CurrentScore;
dbMission.StoppedAt = DateTime.UtcNow;
mission.Dispose();
_allMissions.TryRemove(mission.Id, out _);
}
else
{
// Mission still running - update log and state, keep in queue
dbMission.State = mission.State;
var newLog = mission.GetLog();
if (!string.IsNullOrEmpty(newLog))
{
dbMission.Log += $"{Environment.NewLine}{newLog}";
}
dbMission.Score = mission.CurrentScore;
_runningMissions.Enqueue(mission);
}
}
// Save all changes to database
await dbContext.SaveChangesAsync(cancellationToken);
}
// Collect memory after each cycle
GC.Collect();
stopwatch.Stop();
elapsed = (int)stopwatch.ElapsedMilliseconds;
remaining = interval - elapsed;
// If execution time exceeds 80% of interval, add another cycle
if (elapsed > processTime)
{
remaining += interval;
}
if (remaining > 0)
{
try
{
await Task.Delay(remaining, cancellationToken);
}
catch (OperationCanceledException)
{
break;
}
}
}
// Cleanup on stop: dispose all unstarted Missions
while (_idleMissions.TryDequeue(out var mission))
{
mission.Dispose();
_allMissions.TryRemove(mission.Id, out _);
}
// Cancel and save state of all running Missions
using (var scope = _scopeFactory.CreateScope())
{
var dbContext = scope.ServiceProvider.GetRequiredService<ScriptEngineDbContext>();
while (_runningMissions.TryDequeue(out var mission))
{
try
{
mission.Cancel("engin is stopping");
// Wait for stop, but only timeout if actually executing
mission.WaitForStop();
}
catch (Exception ex)
{
// Log error but continue disposing
_logger.LogError(ex, $"Error stopping mission '{mission.Name}'");
}
finally
{
// Update final state to database
try
{
var dbMission = await dbContext.InstanceMissions.FindAsync([mission.Id], CancellationToken.None);
if (dbMission != null)
{
dbMission.State = mission.State;
dbMission.Log += $"{Environment.NewLine}{mission.GetLog()}";
dbMission.Score = mission.CurrentScore;
dbMission.StoppedAt = DateTime.UtcNow;
}
}
catch (Exception ex)
{
// Log error but continue
_logger.LogError(ex, $"Error updating mission '{mission.Name}' state in database");
}
mission.Dispose();
_allMissions.TryRemove(mission.Id, out _);
}
}
await dbContext.SaveChangesAsync(CancellationToken.None);
}
// Signal that stopping is complete
_stoppedWaitHandle.Set();
}
/// <summary>
/// Resets all mission models. Clears all mission models.
/// Only allowed when state is Idle.
/// </summary>
public MessageResult Reset()
{
try
{
lock (_stateLockObject)
{
if (_currentState != MissionManagerState.Idle)
return new MessageResult(false, $"Cannot reset mission models when MissionManager is in state: {_currentState}");
}
lock (_lockObject)
{
var count = _missionModels.Count;
_missionModels.Clear();
return new MessageResult(true, $"Reset {count} mission model(s) successfully");
}
}
catch (Exception ex)
{
return new MessageResult(false, $"Failed to reset mission models: {ex.Message}");
}
}
/// <summary>
/// Loads all mission models from a collection of ScriptMissionModel. This clears existing mission models first.
/// Only allowed when state is Idle.
/// </summary>
/// <param name="missionModels">The collection of mission models to load.</param>
public MessageResult Load(IEnumerable<ScriptMissionModel> missionModels)
{
try
{
if (missionModels == null)
throw new ArgumentNullException(nameof(missionModels));
lock (_stateLockObject)
{
if (_currentState != MissionManagerState.Idle)
return new MessageResult(false, $"Cannot load mission models when MissionManager is in state: {_currentState}");
}
// Reset existing mission models first
var resetResult = Reset();
if (!resetResult.IsSuccess)
return resetResult;
// Load all mission models
lock (_lockObject)
{
var loadedCount = 0;
var errorCount = 0;
var errors = new List<string>();
foreach (var model in missionModels)
{
if (string.IsNullOrWhiteSpace(model.Name))
continue;
try
{
_missionModels.TryAdd(model.Name, model);
loadedCount++;
}
catch (Exception ex)
{
errorCount++;
errors.Add($"Failed to load mission model '{model.Name}': {ex.Message}");
}
}
if (errorCount > 0)
{
return new MessageResult(false,
$"Loaded {loadedCount} mission model(s) successfully, {errorCount} failed. Errors: {string.Join("; ", errors)}");
}
return new MessageResult(true, $"Loaded {loadedCount} mission model(s) successfully");
}
}
catch (Exception ex)
{
return new MessageResult(false, $"Failed to load mission models: {ex.Message}");
}
}
/// <summary>
/// Creates a mission instance with parameters provided as a dictionary (matched by parameter name).
/// Only allowed when state is Running.
/// </summary>
/// <param name="missionName">The name of the mission model.</param>
/// <param name="parameters">Dictionary of parameter values keyed by parameter name.</param>
/// <returns>MessageResult containing the mission ID if successful.</returns>
public MessageResult<Guid> CreateMission(string missionName, Dictionary<string, object?> parameters)
{
try
{
if (string.IsNullOrWhiteSpace(missionName))
return new MessageResult<Guid>(false, default, "Mission name cannot be null or empty");
ArgumentNullException.ThrowIfNull(parameters);
lock (_stateLockObject)
{
if (_currentState != MissionManagerState.Running)
return new MessageResult<Guid>(false, default, $"Cannot create mission when MissionManager is in state: {_currentState}");
}
if (!_missionModels.TryGetValue(missionName, out var model))
return new MessageResult<Guid>(false, default, $"Mission model '{missionName}' not found");
// Convert dictionary to ScriptMissionParameterModel list
var parameterModels = new List<ScriptMissionParameterModel>();
foreach (var paramModel in model.Parameters)
{
// Skip CancellationToken parameters
if (paramModel.Type == typeof(CancellationToken))
continue;
// Get value from dictionary or use default
object? value = parameters.TryGetValue(paramModel.Name, out var paramValue)
? paramValue
: paramModel.DefaultValue;
parameterModels.Add(new ScriptMissionParameterModel(paramModel.Name, paramModel.Type, value));
}
var missionId = Guid.NewGuid();
return CreateMissionInternal(missionName, parameterModels, missionId);
}
catch (Exception ex)
{
return new MessageResult<Guid>(false, default, $"Failed to create mission: {ex.Message}");
}
}
/// <summary>
/// Creates a mission instance with parameters provided as an object array (matched by order, skipping CancellationToken).
/// Only allowed when state is Running.
/// </summary>
/// <param name="missionName">The name of the mission model.</param>
/// <param name="parameters">Array of parameter values in order (CancellationToken parameters in model are skipped).</param>
/// <returns>MessageResult containing the mission ID if successful.</returns>
public MessageResult<Guid> CreateMission(string missionName, object[] parameters)
{
try
{
if (string.IsNullOrWhiteSpace(missionName))
return new MessageResult<Guid>(false, default, "Mission name cannot be null or empty");
ArgumentNullException.ThrowIfNull(parameters);
lock (_stateLockObject)
{
if (_currentState != MissionManagerState.Running)
return new MessageResult<Guid>(false, default, $"Cannot create mission when MissionManager is in state: {_currentState}");
}
if (!_missionModels.TryGetValue(missionName, out var model))
return new MessageResult<Guid>(false, default, $"Mission model '{missionName}' not found");
// Convert object array to ScriptMissionParameterModel list
// Skip CancellationToken parameters when mapping
var parameterModels = new List<ScriptMissionParameterModel>();
var paramIndex = 0;
foreach (var paramModel in model.Parameters)
{
// Skip CancellationToken parameters
if (paramModel.Type == typeof(CancellationToken))
continue;
// Get value from array or use default
object? value = paramIndex < parameters.Length
? parameters[paramIndex]
: paramModel.DefaultValue;
parameterModels.Add(new ScriptMissionParameterModel(paramModel.Name, paramModel.Type, value));
paramIndex++;
}
// Validate that all provided parameters were used
if (paramIndex < parameters.Length)
{
return new MessageResult<Guid>(false, default, $"Too many parameters provided. Expected {paramIndex} parameters (excluding CancellationToken), but got {parameters.Length}.");
}
var missionId = Guid.NewGuid();
return CreateMissionInternal(missionName, parameterModels, missionId);
}
catch (Exception ex)
{
return new MessageResult<Guid>(false, default, $"Failed to create mission: {ex.Message}");
}
}
private MessageResult<Guid> CreateMissionInternal(string missionName, IEnumerable<ScriptMissionParameterModel> parameters, Guid missionId)
{
try
{
if (!_missionModels.TryGetValue(missionName, out var model))
return new MessageResult<Guid>(false, default, $"Mission model '{missionName}' not found");
// Populate MissionParameters dictionary with provided parameters
var missionParameters = new Dictionary<string, object?>();
foreach (var param in parameters)
{
// Use DefaultValue from parameter (which may have been set to actual value by caller)
missionParameters[param.Name] = param.DefaultValue;
}
// Add CancellationToken parameters from model if they exist
// (These are skipped in CreateMission overloads but need to be in MissionParameters for script execution)
foreach (var paramModel in model.Parameters)
{
if (paramModel.Type == typeof(CancellationToken))
{
// CancellationToken will be provided by mission's internal token source
missionParameters[paramModel.Name] = CancellationToken.None;
}
}
// Serialize parameters to JSON (include all parameters from model, including CancellationToken)
var allParametersForJson = model.Parameters.Select(p => new ScriptMissionParameterDto(p.Name, p.Type.FullName ?? "", p.DefaultValue?.ToString() ?? "null"));
var parametersJson = JsonSerializer.Serialize(allParametersForJson);
// Create InstanceMission in database FIRST with initial values
using (var scope = _scopeFactory.CreateScope())
{
var dbContext = scope.ServiceProvider.GetRequiredService<ScriptEngineDbContext>();
var dbMission = new InstanceMission
{
Id = missionId,
MissionName = missionName,
CreatedAt = DateTime.UtcNow,
Parameters = parametersJson,
TotalScore = model.TotalScore,
State = ScriptMissionState.Idle,
Score = 0,
StoppedAt = DateTime.UtcNow,
Log = string.Empty
};
dbContext.InstanceMissions.Add(dbMission);
dbContext.SaveChanges();
}
// Create LoggerMission with mission ID
var loggerMission = new LoggerMission(missionId, _consoleHubContext);
// Create ScriptEngineGlobals with LoggerMission
var scriptEngineGlobals = new ScriptEngineGlobals(loggerMission, _scopeFactory);
var robotNetDict = ScriptHelper.ConvertGlobalsToDictionary(scriptEngineGlobals, typeof(IScriptGlobals));
// Get AppApis with mission ID
var appApisDict = _scriptResource.GetMissionGlobals(missionId, CancellationToken.None);
// Get GlobalVariables from VariableManager
var globalVariablesDict = _variableManager.Globals;
// Create ScriptGlobals with LoggerMission, mission ID, GlobalVariables, and MissionParameters
var populatedGlobals = new ScriptGlobals(
robotNetDict,
appApisDict,
globalVariablesDict,
missionParameters
);
// Create ScriptMission instance
var mission = new ScriptMission(missionId, model, populatedGlobals);
// Add to idle queue
_idleMissions.Enqueue(mission);
_allMissions.TryAdd(missionId, mission);
return new MessageResult<Guid>(true, missionId, $"Mission '{missionName}' created successfully with ID: {missionId}");
}
catch (Exception ex)
{
return new MessageResult<Guid>(false, default, $"Failed to create mission '{missionName}': {ex.Message}");
}
}
/// <summary>
/// Gets a mission instance by ID.
/// </summary>
public ScriptMission? GetMission(Guid missionId)
{
_allMissions.TryGetValue(missionId, out var mission);
return mission;
}
/// <summary>
/// Gets a mission model by name.
/// </summary>
public ScriptMissionModel? GetMissionModel(string name)
{
_missionModels.TryGetValue(name, out var model);
return model;
}
/// <summary>
/// Gets all mission instances.
/// </summary>
public IEnumerable<ScriptMission> GetAllMissions()
{
return _allMissions.Values;
}
/// <summary>
/// Gets all mission models as ScriptMissionDto array.
/// </summary>
public ScriptMissionDto[] GetScriptMissions()
{
lock (_lockObject)
{
return _missionModels.Values.Select(m => new ScriptMissionDto(
m.Name,
m.Parameters.Select(p => new ScriptMissionParameterDto(
p.Name,
p.Type.FullName ?? p.Type.Name,
p.DefaultValue?.ToString())).ToArray())).ToArray();
}
}
/// <summary>
/// Finds specific mission models by names as ScriptMissionDto array.
/// </summary>
public ScriptMissionDto[] FindScriptMissions(string[] names)
{
if (names == null)
throw new ArgumentNullException(nameof(names));
lock (_lockObject)
{
return _missionModels.Values
.Where(m => names.Contains(m.Name))
.Select(m => new ScriptMissionDto(
m.Name,
m.Parameters.Select(p => new ScriptMissionParameterDto(
p.Name,
p.Type.FullName ?? p.Type.Name,
p.DefaultValue?.ToString())).ToArray())).ToArray();
}
}
/// <summary>
/// Starts the MissionManager. This will start missions with AutoStart == true.
/// </summary>
public MessageResult Start()
{
try
{
_stateMachine.Fire(MissionManagerTrigger.Start);
return new MessageResult(true, "MissionManager started successfully");
}
catch (Exception ex)
{
return new MessageResult(false, $"Failed to start MissionManager: {ex.Message}");
}
}
/// <summary>
/// Stops the MissionManager. This will ensure all missions are stopped.
/// </summary>
public MessageResult Stop()
{
try
{
_stateMachine.Fire(MissionManagerTrigger.Stop);
return new MessageResult(true, "MissionManager stop initiated");
}
catch (Exception ex)
{
return new MessageResult(false, $"Failed to stop MissionManager: {ex.Message}");
}
}
/// <summary>
/// Checks if all missions are not running.
/// </summary>
public bool AreAllMissionsNotRunning()
{
lock (_queueLockObject)
{
return _idleMissions.IsEmpty && _runningMissions.IsEmpty;
}
}
/// <summary>
/// Gets the count of mission models.
/// </summary>
public int MissionModelCount => _missionModels.Count;
/// <summary>
/// Gets the count of active missions.
/// </summary>
public int ActiveMissionCount => _allMissions.Count;
/// <summary>
/// Disposes the MissionManager and all missions.
/// </summary>
public void Dispose()
{
if (_disposed)
return;
// Stop state machine first
try
{
_stateMachine.Stop();
}
catch
{
// Ignore errors when stopping state machine
}
// Stop running handler
_runningHandlerCts?.Cancel();
if (_runningHandlerTask != null)
{
try
{
_runningHandlerTask.Wait(TimeSpan.FromSeconds(5));
}
catch
{
// Ignore errors
}
}
// Dispose all missions
lock (_queueLockObject)
{
while (_idleMissions.TryDequeue(out var mission))
{
try
{
mission.Dispose();
}
catch
{
// Ignore disposal errors
}
}
while (_runningMissions.TryDequeue(out var mission))
{
try
{
mission.Dispose();
}
catch
{
// Ignore disposal errors
}
}
}
_allMissions.Clear();
_missionModels.Clear();
_runningHandlerCts?.Dispose();
_stoppedWaitHandle.Dispose();
_disposed = true;
GC.SuppressFinalize(this);
}
}

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);
}

View File

@@ -0,0 +1,32 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<FrameworkReference Include="Microsoft.AspNetCore.App" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Appccelerate.StateMachine" Version="6.0.0" />
<PackageReference Include="Microsoft.CodeAnalysis.CSharp.Scripting" Version="5.0.0" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Relational" Version="10.0.3" />
<PackageReference Include="NModbus4" Version="3.0.0-alpha2" />
<PackageReference Include="OPCFoundation.NetStandard.Opc.Ua" Version="1.5.378.106" />
<PackageReference Include="Newtonsoft.Json" Version="13.0.4" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\Shared\RobotNet10.ScriptEngine.Shared\RobotNet10.ScriptEngine.Shared.csproj" />
<ProjectReference Include="..\..\Shared\RobotNet10.Shared\RobotNet10.Shared.csproj" />
<ProjectReference Include="..\RobotNet10.Script\RobotNet10.Script.csproj" />
</ItemGroup>
<ItemGroup>
<Folder Include="Enums\" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,645 @@
using Appccelerate.StateMachine;
using Appccelerate.StateMachine.Machine;
using Microsoft.AspNetCore.SignalR;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging;
using RobotNet10.ScriptEngine.Enums;
using RobotNet10.ScriptEngine.Helpers;
using RobotNet10.ScriptEngine.HubContexts;
using RobotNet10.ScriptEngine.Hubs;
using RobotNet10.ScriptEngine.Models;
using RobotNet10.ScriptEngine.Shared;
using RobotNet10.Shared;
using System.Text;
namespace RobotNet10.ScriptEngine;
/// <summary>
/// Core ScriptEngine class that manages script compilation, execution, and state machine.
/// </summary>
public class ScriptEngine : IDisposable
{
private readonly PassiveStateMachine<ScriptEngineState, ScriptEngineTrigger> _stateMachine;
private readonly ScriptBuilder _scriptBuilder;
private readonly FileManager _fileManager;
private readonly VariableManager _variableManager;
private readonly TaskManager _taskManager;
private readonly MissionManager _missionManager;
private readonly ILogger<ScriptEngine> _logger;
private readonly IHubContext<ScriptManagerHub> _hubContext;
private readonly ConsoleHubContext _consoleHubContext;
private readonly Lock _stateLockObject = new();
private ScriptEngineState _currentState = ScriptEngineState.Initializing;
private bool _disposed;
/// <summary>
/// Gets the current state of the ScriptEngine.
/// </summary>
public ScriptEngineState State => _currentState;
/// <summary>
/// Gets the FileManager instance.
/// </summary>
public FileManager FileManager => _fileManager;
/// <summary>
/// Gets the VariableManager instance.
/// </summary>
public VariableManager VariableManager => _variableManager;
/// <summary>
/// Gets the TaskManager instance.
/// </summary>
public TaskManager TaskManager => _taskManager;
/// <summary>
/// Gets the MissionManager instance.
/// </summary>
public MissionManager MissionManager => _missionManager;
/// <summary>
/// Initializes a new instance of ScriptEngine.
/// </summary>
public ScriptEngine(
IScriptEngineResource scriptResource,
IConfiguration configuration,
FileManager fileManager,
VariableManager variableManager,
TaskManager taskManager,
MissionManager missionManager,
ILogger<ScriptEngine> logger,
IHubContext<ScriptManagerHub> hubContext,
ConsoleHubContext consoleHubContext)
{
_fileManager = fileManager ?? throw new ArgumentNullException(nameof(fileManager));
_variableManager = variableManager ?? throw new ArgumentNullException(nameof(variableManager));
_taskManager = taskManager ?? throw new ArgumentNullException(nameof(taskManager));
_missionManager = missionManager ?? throw new ArgumentNullException(nameof(missionManager));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_hubContext = hubContext ?? throw new ArgumentNullException(nameof(hubContext));
_consoleHubContext = consoleHubContext ?? throw new ArgumentNullException(nameof(consoleHubContext));
_scriptBuilder = new ScriptBuilder(scriptResource, configuration["ScriptEngine:RuntimeDllFolder"] ?? "dlls");
var builder = new StateMachineDefinitionBuilder<ScriptEngineState, ScriptEngineTrigger>();
// Configure state machine transitions according to StateMachine_Design.md
ConfigureStateMachine(builder);
_stateMachine = builder
.WithInitialState(ScriptEngineState.Initializing)
.Build()
.CreatePassiveStateMachine();
_currentState = ScriptEngineState.Initializing;
_stateMachine.Start();
// Auto-transition from Initializing to Idle
_stateMachine.Fire(ScriptEngineTrigger.InitializationCompleted);
}
private void ConfigureStateMachine(StateMachineDefinitionBuilder<ScriptEngineState, ScriptEngineTrigger> builder)
{
// Initializing state
builder.In(ScriptEngineState.Initializing)
.ExecuteOnEntry(() => { lock (_stateLockObject) { _currentState = ScriptEngineState.Initializing; } })
.On(ScriptEngineTrigger.InitializationCompleted)
.Goto(ScriptEngineState.Idle)
.On(ScriptEngineTrigger.Reset)
.Goto(ScriptEngineState.Resetting);
// Resetting state
builder.In(ScriptEngineState.Resetting)
.ExecuteOnEntry(() => { lock (_stateLockObject) { _currentState = ScriptEngineState.Resetting; } OnEnterResetting(); })
.On(ScriptEngineTrigger.ResettingCompleted)
.Goto(ScriptEngineState.Idle)
.On(ScriptEngineTrigger.FaultOccurred)
.Goto(ScriptEngineState.Fault);
// Idle state - scripts can be edited
builder.In(ScriptEngineState.Idle)
.ExecuteOnEntry(() => { lock (_stateLockObject) { _currentState = ScriptEngineState.Idle; } OnEnterIdle(); })
.On(ScriptEngineTrigger.Build)
.Goto(ScriptEngineState.Building)
.On(ScriptEngineTrigger.Reset)
.Goto(ScriptEngineState.Resetting);
// Building state
builder.In(ScriptEngineState.Building)
.ExecuteOnEntry(() => { lock (_stateLockObject) { _currentState = ScriptEngineState.Building; } OnEnterBuilding(); })
.On(ScriptEngineTrigger.BuildingCompleted)
.Goto(ScriptEngineState.Ready)
.On(ScriptEngineTrigger.BuildErrorOccurred)
.Goto(ScriptEngineState.BuildError)
.On(ScriptEngineTrigger.FaultOccurred)
.Goto(ScriptEngineState.Fault);
// BuildError state
builder.In(ScriptEngineState.BuildError)
.ExecuteOnEntry(() => { lock (_stateLockObject) { _currentState = ScriptEngineState.BuildError; } OnEnterBuildError(); })
.On(ScriptEngineTrigger.Reset)
.Goto(ScriptEngineState.Idle)
.On(ScriptEngineTrigger.Build)
.Goto(ScriptEngineState.Building);
// Ready state - scripts compiled successfully, cannot edit
builder.In(ScriptEngineState.Ready)
.ExecuteOnEntry(() => { lock (_stateLockObject) { _currentState = ScriptEngineState.Ready; } OnEnterReady(); })
.On(ScriptEngineTrigger.Start)
.Goto(ScriptEngineState.Starting)
.On(ScriptEngineTrigger.Reset)
.Goto(ScriptEngineState.Idle)
.On(ScriptEngineTrigger.Build)
.Goto(ScriptEngineState.Building);
// Starting state
builder.In(ScriptEngineState.Starting)
.ExecuteOnEntry(() => { lock (_stateLockObject) { _currentState = ScriptEngineState.Starting; } OnEnterStarting(); })
.On(ScriptEngineTrigger.StartingCompleted)
.Goto(ScriptEngineState.Running)
.On(ScriptEngineTrigger.FaultOccurred)
.Goto(ScriptEngineState.Fault);
// Running state - tasks and missions can execute
builder.In(ScriptEngineState.Running)
.ExecuteOnEntry(() => { lock (_stateLockObject) { _currentState = ScriptEngineState.Running; } OnEnterRunning(); })
.On(ScriptEngineTrigger.Stop)
.Goto(ScriptEngineState.Stopping)
.On(ScriptEngineTrigger.Reset)
.Goto(ScriptEngineState.Resetting)
.On(ScriptEngineTrigger.FaultOccurred)
.Goto(ScriptEngineState.Fault);
// Stopping state
builder.In(ScriptEngineState.Stopping)
.ExecuteOnEntry(() => { lock (_stateLockObject) { _currentState = ScriptEngineState.Stopping; } OnEnterStopping(); })
.On(ScriptEngineTrigger.StoppingCompleted)
.Goto(ScriptEngineState.Ready)
.On(ScriptEngineTrigger.FaultOccurred)
.Goto(ScriptEngineState.Fault);
// Fault state
builder.In(ScriptEngineState.Fault)
.ExecuteOnEntry(() => { lock (_stateLockObject) { _currentState = ScriptEngineState.Fault; } OnEnterFault(); })
.On(ScriptEngineTrigger.Reset)
.Goto(ScriptEngineState.Resetting);
}
#region State Machine Event Handlers
private void OnEnterIdle()
{
_fileManager.SetState(ScriptEngineState.Idle);
var message = "ScriptEngine entered Idle state. Scripts can be edited.";
_logger.LogInformation(message);
_consoleHubContext.LogInfo(message);
_ = NotifyStateChanged(ScriptEngineState.Idle);
}
private void OnEnterBuilding()
{
_fileManager.SetState(ScriptEngineState.Building);
var message = "ScriptEngine entered Building state. Compiling scripts...";
_logger.LogInformation(message);
_consoleHubContext.LogInfo(message);
_ = NotifyStateChanged(ScriptEngineState.Building);
// Fire and forget async operation with proper error handling
_ = Task.Run(async () =>
{
try
{
// Aggregate all script files code
var aggregatedCode = await _fileManager.AggregateAllCodeAsync();
if (string.IsNullOrWhiteSpace(aggregatedCode))
{
var warningMessage = "No script files found to compile.";
_logger.LogWarning(warningMessage);
_consoleHubContext.LogWarning(warningMessage);
_stateMachine.Fire(ScriptEngineTrigger.BuildingCompleted);
return;
}
// Build using ScriptBuilder
_scriptBuilder.Build(aggregatedCode, out var variables, out var tasks, out var missions);
// Load into managers
_variableManager.Load(variables);
var taskLoadResult = _taskManager.Load(tasks);
var missionLoadResult = _missionManager.Load(missions);
if (!taskLoadResult.IsSuccess)
{
var errorMessage = $"Failed to load tasks: {taskLoadResult.Message}";
_logger.LogError(errorMessage);
_consoleHubContext.LogError(errorMessage);
_stateMachine.Fire(ScriptEngineTrigger.BuildErrorOccurred);
return;
}
if (!missionLoadResult.IsSuccess)
{
var errorMessage = $"Failed to load missions: {missionLoadResult.Message}";
_logger.LogError(errorMessage);
_consoleHubContext.LogError(errorMessage);
_stateMachine.Fire(ScriptEngineTrigger.BuildErrorOccurred);
return;
}
var successMessage = $"Build completed successfully. Loaded {variables.Count()} variables, {tasks.Count()} tasks, {missions.Count()} missions.";
_logger.LogInformation(successMessage);
_consoleHubContext.LogInfo(successMessage);
_stateMachine.Fire(ScriptEngineTrigger.BuildingCompleted);
}
catch (ScriptCompilationException ex)
{
var errorMessage = $"Script compilation failed: {ex.Message}";
_logger.LogError(ex, "Script compilation failed.");
_consoleHubContext.LogError(errorMessage);
_stateMachine.Fire(ScriptEngineTrigger.BuildErrorOccurred);
}
catch (Exception ex)
{
var errorMessage = $"Unexpected error during script building: {ex.Message}";
_logger.LogError(ex, "Unexpected error during script building.");
_consoleHubContext.LogError(errorMessage);
_stateMachine.Fire(ScriptEngineTrigger.FaultOccurred);
}
});
}
private void OnEnterBuildError()
{
_fileManager.SetState(ScriptEngineState.BuildError);
var errorMessage = "ScriptEngine entered BuildError state. Script compilation failed.";
_logger.LogWarning(errorMessage);
_consoleHubContext.LogError(errorMessage);
_ = NotifyStateChanged(ScriptEngineState.BuildError);
}
private void OnEnterReady()
{
_fileManager.SetState(ScriptEngineState.Ready);
var message = "ScriptEngine entered Ready state. Scripts compiled successfully, ready to start.";
_logger.LogInformation(message);
_consoleHubContext.LogInfo(message);
_ = NotifyStateChanged(ScriptEngineState.Ready);
}
private void OnEnterStarting()
{
_fileManager.SetState(ScriptEngineState.Starting);
var message = "ScriptEngine entered Starting state. Starting TaskManager and MissionManager...";
_logger.LogInformation(message);
_consoleHubContext.LogInfo(message);
_ = NotifyStateChanged(ScriptEngineState.Starting);
// Fire and forget async operation with proper error handling
_ = Task.Run(async () =>
{
try
{
// Start TaskManager and MissionManager
var taskStartResult = _taskManager.Start();
var missionStartResult = _missionManager.Start();
if (!taskStartResult.IsSuccess)
{
var errorMessage = $"Failed to start TaskManager: {taskStartResult.Message}";
_logger.LogError(errorMessage);
_consoleHubContext.LogError(errorMessage);
_stateMachine.Fire(ScriptEngineTrigger.FaultOccurred);
return;
}
if (!missionStartResult.IsSuccess)
{
var errorMessage = $"Failed to start MissionManager: {missionStartResult.Message}";
_logger.LogError(errorMessage);
_consoleHubContext.LogError(errorMessage);
_stateMachine.Fire(ScriptEngineTrigger.FaultOccurred);
return;
}
// Wait a bit to ensure managers are started
await Task.Delay(100);
var successMessage = "TaskManager and MissionManager started successfully.";
_logger.LogInformation(successMessage);
_consoleHubContext.LogInfo(successMessage);
_stateMachine.Fire(ScriptEngineTrigger.StartingCompleted);
}
catch (Exception ex)
{
var errorMessage = $"Unexpected error during starting: {ex.Message}";
_logger.LogError(ex, "Unexpected error during starting.");
_consoleHubContext.LogError(errorMessage);
_stateMachine.Fire(ScriptEngineTrigger.FaultOccurred);
}
});
}
private void OnEnterRunning()
{
_fileManager.SetState(ScriptEngineState.Running);
var message = "ScriptEngine entered Running state. Tasks and missions can execute.";
_logger.LogInformation(message);
_consoleHubContext.LogInfo(message);
_ = NotifyStateChanged(ScriptEngineState.Running);
}
private void OnEnterStopping()
{
_fileManager.SetState(ScriptEngineState.Stopping);
var message = "ScriptEngine entered Stopping state. Stopping TaskManager and MissionManager...";
_logger.LogInformation(message);
_consoleHubContext.LogInfo(message);
_ = NotifyStateChanged(ScriptEngineState.Stopping);
// Fire and forget async operation with proper error handling
_ = Task.Run(async () =>
{
try
{
// Stop TaskManager and MissionManager
var taskStopResult = _taskManager.Stop();
var missionStopResult = _missionManager.Stop();
if (!taskStopResult.IsSuccess)
{
_logger.LogWarning($"Failed to stop TaskManager: {taskStopResult.Message}");
}
if (!missionStopResult.IsSuccess)
{
_logger.LogWarning($"Failed to stop MissionManager: {missionStopResult.Message}");
}
// Wait for all tasks to stop and all missions to not be running
// Only timeout when tasks/missions are actually executing (ScriptRunner running)
var maxWaitTime = TimeSpan.FromSeconds(30);
var checkInterval = TimeSpan.FromMilliseconds(100);
var elapsed = TimeSpan.Zero;
while (elapsed < maxWaitTime)
{
// Check if all tasks are stopped (snapshot collection to avoid race condition)
var tasksSnapshot = _taskManager.Tasks.Values.ToList();
// Check both state and IsExecuting - only consider stopped if not executing
var allTasksStopped = tasksSnapshot.All(t =>
(t.State == ScriptTaskState.Stopped || t.State == ScriptTaskState.Idle || t.State == ScriptTaskState.Error)
&& !t.IsExecuting);
// Check if all missions are not running (snapshot collection to avoid race condition)
var missionsSnapshot = _missionManager.GetAllMissions().ToList();
// Only consider stopped if not in Running state and not executing
var allMissionsNotRunning = missionsSnapshot.All(m =>
m.State != ScriptMissionState.Running && !m.IsExecuting);
if (allTasksStopped && allMissionsNotRunning)
{
var successMessage = "All tasks stopped and all missions are not running.";
_logger.LogInformation(successMessage);
_consoleHubContext.LogInfo(successMessage);
_stateMachine.Fire(ScriptEngineTrigger.StoppingCompleted);
return;
}
await Task.Delay(checkInterval);
elapsed = elapsed.Add(checkInterval);
}
// Only log timeout if there are actually running tasks/missions
var runningTasks = _taskManager.Tasks.Values.Where(t => t.IsExecuting).ToList();
var runningMissions = _missionManager.GetAllMissions().Where(m => m.IsExecuting).ToList();
if (runningTasks.Count > 0 || runningMissions.Count > 0)
{
var warningMessage = $"Timeout waiting for {runningTasks.Count} task(s) and {runningMissions.Count} mission(s) to stop. ScriptRunner may be blocking. Proceeding anyway.";
_logger.LogWarning(warningMessage);
_consoleHubContext.LogWarning(warningMessage);
}
else
{
// All stopped but state machine may not have transitioned - proceed anyway
var infoMessage = "All tasks and missions stopped. Proceeding with state transition.";
_logger.LogInformation(infoMessage);
_consoleHubContext.LogInfo(infoMessage);
}
_stateMachine.Fire(ScriptEngineTrigger.StoppingCompleted);
}
catch (Exception ex)
{
var errorMessage = $"Unexpected error during stopping: {ex.Message}";
_logger.LogError(ex, "Unexpected error during stopping.");
_consoleHubContext.LogError(errorMessage);
_stateMachine.Fire(ScriptEngineTrigger.FaultOccurred);
}
});
}
private void OnEnterResetting()
{
_fileManager.SetState(ScriptEngineState.Resetting);
var message = "ScriptEngine entered Resetting state. Resetting managers...";
_logger.LogInformation(message);
_consoleHubContext.LogInfo(message);
try
{
// Reset all managers
_variableManager.Reset();
var taskResetResult = _taskManager.Reset();
var missionResetResult = _missionManager.Reset();
if (!taskResetResult.IsSuccess)
{
_logger.LogWarning($"Failed to reset TaskManager: {taskResetResult.Message}");
}
if (!missionResetResult.IsSuccess)
{
_logger.LogWarning($"Failed to reset MissionManager: {missionResetResult.Message}");
}
var successMessage = "All managers reset successfully.";
_logger.LogInformation(successMessage);
_consoleHubContext.LogInfo(successMessage);
_stateMachine.Fire(ScriptEngineTrigger.ResettingCompleted);
}
catch (Exception ex)
{
var errorMessage = $"Unexpected error during resetting: {ex.Message}";
_logger.LogError(ex, "Unexpected error during resetting.");
_consoleHubContext.LogError(errorMessage);
_stateMachine.Fire(ScriptEngineTrigger.FaultOccurred);
}
}
private void OnEnterFault()
{
_fileManager.SetState(ScriptEngineState.Fault);
var errorMessage = "ScriptEngine entered Fault state. System error occurred.";
_logger.LogError(errorMessage);
_consoleHubContext.LogError(errorMessage);
_ = NotifyStateChanged(ScriptEngineState.Fault);
}
/// <summary>
/// Notifies all connected clients about state change via SignalR.
/// </summary>
private async Task NotifyStateChanged(ScriptEngineState newState)
{
try
{
await _hubContext.Clients.All.SendAsync("StateChanged", newState);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to notify state change via SignalR.");
}
}
#endregion
#region Public Methods
/// <summary>
/// Builds scripts from all files. Only allowed when state is Idle or BuildError.
/// </summary>
public MessageResult Build()
{
try
{
lock (_stateLockObject)
{
if (_currentState != ScriptEngineState.Idle && _currentState != ScriptEngineState.BuildError)
{
return new MessageResult(false, $"Cannot build when ScriptEngine is in state: {_currentState}");
}
}
_stateMachine.Fire(ScriptEngineTrigger.Build);
return new MessageResult(true, "Build initiated successfully");
}
catch (Exception ex)
{
var errorMessage = $"Failed to initiate build: {ex.Message}";
_logger.LogError(ex, "Failed to initiate build.");
_consoleHubContext.LogError(errorMessage);
return new MessageResult(false, errorMessage);
}
}
/// <summary>
/// Starts the ScriptEngine. Only allowed when state is Ready.
/// </summary>
public MessageResult Start()
{
try
{
lock (_stateLockObject)
{
if (_currentState != ScriptEngineState.Ready)
{
return new MessageResult(false, $"Cannot start when ScriptEngine is in state: {_currentState}");
}
}
_stateMachine.Fire(ScriptEngineTrigger.Start);
return new MessageResult(true, "Start initiated successfully");
}
catch (Exception ex)
{
var errorMessage = $"Failed to initiate start: {ex.Message}";
_logger.LogError(ex, "Failed to initiate start.");
_consoleHubContext.LogError(errorMessage);
return new MessageResult(false, errorMessage);
}
}
/// <summary>
/// Stops the ScriptEngine. Only allowed when state is Running.
/// </summary>
public MessageResult Stop()
{
try
{
lock (_stateLockObject)
{
if (_currentState != ScriptEngineState.Running)
{
return new MessageResult(false, $"Cannot stop when ScriptEngine is in state: {_currentState}");
}
}
_stateMachine.Fire(ScriptEngineTrigger.Stop);
return new MessageResult(true, "Stop initiated successfully");
}
catch (Exception ex)
{
var errorMessage = $"Failed to initiate stop: {ex.Message}";
_logger.LogError(ex, "Failed to initiate stop.");
_consoleHubContext.LogError(errorMessage);
return new MessageResult(false, errorMessage);
}
}
/// <summary>
/// Resets the ScriptEngine. Allowed from Idle, Ready, BuildError, Running, or Fault.
/// </summary>
public MessageResult Reset()
{
try
{
lock (_stateLockObject)
{
if (_currentState != ScriptEngineState.Idle &&
_currentState != ScriptEngineState.Ready &&
_currentState != ScriptEngineState.BuildError &&
_currentState != ScriptEngineState.Running &&
_currentState != ScriptEngineState.Fault)
{
return new MessageResult(false, $"Cannot reset when ScriptEngine is in state: {_currentState}");
}
}
_stateMachine.Fire(ScriptEngineTrigger.Reset);
return new MessageResult(true, "Reset initiated successfully");
}
catch (Exception ex)
{
var errorMessage = $"Failed to initiate reset: {ex.Message}";
_logger.LogError(ex, "Failed to initiate reset.");
_consoleHubContext.LogError(errorMessage);
return new MessageResult(false, errorMessage);
}
}
#endregion
#region IDisposable
public void Dispose()
{
if (_disposed)
return;
try
{
_stateMachine?.Stop();
_taskManager?.Dispose();
_missionManager?.Dispose();
}
catch (Exception ex)
{
_logger.LogError(ex, "Error during ScriptEngine disposal.");
}
_disposed = true;
GC.SuppressFinalize(this);
}
#endregion
}

View File

@@ -0,0 +1,72 @@
using Microsoft.AspNetCore.Builder;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using RobotNet10.ScriptEngine.Data;
using RobotNet10.ScriptEngine.HubContexts;
using RobotNet10.ScriptEngine.Hubs;
using RobotNet10.ScriptEngine.Shared;
namespace RobotNet10.ScriptEngine;
public static class ScriptEngineExtensions
{
/// <summary>
/// Adds ScriptEngine services to the service collection.
/// </summary>
/// <typeparam name="TScriptEngineResource">The type implementing IScriptEngineResource.</typeparam>
/// <param name="services">The service collection.</param>
/// <param name="optionsAction">The action to configure DbContext options.</param>
extension(IServiceCollection services)
{
public void AddScriptEngine<TScriptEngineResource>(Action<DbContextOptionsBuilder> optionsAction)
where TScriptEngineResource : class, IScriptEngineResource
{
// Note: SignalR must be configured before calling this method
// services.AddSignalR() should be called in Program.cs before this method
services.AddSingleton<IScriptEngineResource, TScriptEngineResource>()
.AddSingleton<ConsoleHubContext>()
.AddSingleton<VariableManager>()
.AddSingleton<FileManager>()
.AddSingleton<TaskManager>()
.AddSingleton<MissionManager>()
.AddSingleton<ScriptEngine>()
.AddDbContext<ScriptEngineDbContext>(optionsAction);
}
}
/// <summary>
/// Maps ScriptEngine SignalR hubs to endpoints.
/// This extension method should be called in Program.cs after app.Build().
/// Example: app.MapScriptEngineHubs();
/// </summary>
/// <param name="app">The web application.</param>
extension(WebApplication app)
{
public void MapScriptEngineHubs()
{
app.MapHub<ConsoleHub>(HubEndpoints.ScriptConsoleHubPath);
app.MapHub<ScriptManagerHub>(HubEndpoints.ScriptManagerHubPath);
app.MapHub<FileManagerHub>(HubEndpoints.ScriptFileManagerHubPath);
app.MapHub<InstanceMissionHub>(HubEndpoints.InstanceMissionHubPath);
}
}
/// <summary>
/// Seeds the ScriptEngine database by applying migrations.
/// </summary>
/// <param name="serviceProvider">The service provider.</param>
extension(IServiceProvider serviceProvider)
{
public async Task SeedScriptEngineDbAsync()
{
using var scope = serviceProvider.CreateScope();
using var appDb = scope.ServiceProvider.GetRequiredService<ScriptEngineDbContext>();
await appDb.Database.MigrateAsync();
await appDb.Database.EnsureCreatedAsync();
await appDb.SaveChangesAsync();
}
}
}

View File

@@ -0,0 +1,607 @@
using Appccelerate.StateMachine;
using Appccelerate.StateMachine.Machine;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using RobotNet10.Script;
using RobotNet10.ScriptEngine.Helpers;
using RobotNet10.ScriptEngine.HubContexts;
using RobotNet10.ScriptEngine.Models;
using RobotNet10.ScriptEngine.Shared;
using RobotNet10.Shared;
using System.Collections.Concurrent;
namespace RobotNet10.ScriptEngine;
/// <summary>
/// State enum for TaskManager.
/// </summary>
public enum TaskManagerState
{
Idle = 0,
Running,
Stopping,
}
/// <summary>
/// Triggers for TaskManager state machine.
/// </summary>
public enum TaskManagerTrigger
{
Start,
Stop,
StoppingCompleted,
}
/// <summary>
/// Manages script tasks with state machine support.
/// </summary>
public class TaskManager : IDisposable
{
private readonly PassiveStateMachine<TaskManagerState, TaskManagerTrigger> _stateMachine;
private readonly ConcurrentDictionary<string, ScriptTask> _tasks = new();
private readonly Lock _lockObject = new();
private readonly Lock _stateLockObject = new();
private readonly VariableManager _variableManager;
private readonly IScriptEngineResource _scriptResource;
private readonly ILogger<ScriptEngineGlobals> _logger;
private readonly IServiceScopeFactory _scopeFactory;
private readonly ConsoleHubContext _consoleHubContext;
private readonly IConfiguration _configuration;
private bool _disposed;
private TaskManagerState _currentState = TaskManagerState.Idle;
/// <summary>
/// Gets the current state of the TaskManager.
/// </summary>
public TaskManagerState State => _currentState;
/// <summary>
/// Gets all tasks.
/// </summary>
public IReadOnlyDictionary<string, ScriptTask> Tasks => _tasks;
/// <summary>
/// Initializes a new instance of TaskManager.
/// </summary>
public TaskManager(
VariableManager variableManager,
IScriptEngineResource scriptResource,
ILogger<ScriptEngineGlobals> logger,
IServiceScopeFactory scopeFactory,
ConsoleHubContext consoleHubContext,
IConfiguration configuration)
{
_variableManager = variableManager ?? throw new ArgumentNullException(nameof(variableManager));
_scriptResource = scriptResource ?? throw new ArgumentNullException(nameof(scriptResource));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_scopeFactory = scopeFactory ?? throw new ArgumentNullException(nameof(scopeFactory));
_consoleHubContext = consoleHubContext ?? throw new ArgumentNullException(nameof(consoleHubContext));
_configuration = configuration ?? throw new ArgumentNullException(nameof(configuration));
var builder = new StateMachineDefinitionBuilder<TaskManagerState, TaskManagerTrigger>();
// Idle state - can add/remove tasks
builder.In(TaskManagerState.Idle)
.ExecuteOnEntry(() => { lock (_stateLockObject) { _currentState = TaskManagerState.Idle; } })
.On(TaskManagerTrigger.Start)
.Goto(TaskManagerState.Running)
.Execute(() => { lock (_stateLockObject) { _currentState = TaskManagerState.Running; } OnEnterRunning(); });
// Running state - cannot add/remove tasks
builder.In(TaskManagerState.Running)
.ExecuteOnEntry(() => { lock (_stateLockObject) { _currentState = TaskManagerState.Running; } })
.On(TaskManagerTrigger.Stop)
.Goto(TaskManagerState.Stopping)
.Execute(() => { lock (_stateLockObject) { _currentState = TaskManagerState.Stopping; } OnEnterStopping(); });
// Stopping state - cannot add/remove tasks, waiting for all tasks to stop
builder.In(TaskManagerState.Stopping)
.ExecuteOnEntry(() => { lock (_stateLockObject) { _currentState = TaskManagerState.Stopping; } })
.On(TaskManagerTrigger.StoppingCompleted)
.Goto(TaskManagerState.Idle)
.Execute(() => { lock (_stateLockObject) { _currentState = TaskManagerState.Idle; } });
_stateMachine = builder
.WithInitialState(TaskManagerState.Idle)
.Build()
.CreatePassiveStateMachine();
_stateMachine.Start();
}
private void OnEnterRunning()
{
// Start all tasks with AutoStart == true
lock (_lockObject)
{
foreach (var task in _tasks.Values)
{
if (task.AutoStart)
{
try
{
task.Start();
}
catch (Exception ex)
{
// Log error but continue starting other tasks
_logger.LogError(ex, $"Failed to start task '{task.Name}'");
_consoleHubContext.LogErrorToTask(task.Name, $"Failed to start task: {ex.Message}");
}
}
}
}
}
private void OnEnterStopping()
{
// Fire and forget async operation with proper error handling
_ = Task.Run(async () =>
{
// Stop all tasks and wait for them to be stopped
var stopTasks = new List<Task>();
lock (_lockObject)
{
foreach (var task in _tasks.Values)
{
stopTasks.Add(Task.Run(() =>
{
try
{
// Try to stop the task
task.Stop();
// Wait for task to stop, but only timeout if actually executing
// This will also wait for state machine transition if task is not executing
WaitForTaskStopped(task);
}
catch (Exception ex)
{
// Log error but continue stopping other tasks
_logger.LogError(ex, $"Failed to stop task '{task.Name}'");
_consoleHubContext.LogErrorToTask(task.Name, $"Failed to stop task: {ex.Message}");
}
}));
}
}
// Wait for all tasks to stop (with timeout)
try
{
await Task.WhenAll(stopTasks).WaitAsync(TimeSpan.FromSeconds(35));
}
catch (TimeoutException)
{
// Check if any tasks are still executing
var stillExecuting = _tasks.Values.Where(t => t.IsExecuting).ToList();
if (stillExecuting.Count > 0)
{
_logger.LogWarning($"Timeout waiting for {stillExecuting.Count} task(s) to stop. ScriptRunner may be blocking.");
_consoleHubContext.LogWarning($"Timeout waiting for {stillExecuting.Count} task(s) to stop. ScriptRunner may be blocking.");
}
else
{
_logger.LogInformation("All tasks stopped. Proceeding with state transition.");
_consoleHubContext.LogInfo("All tasks stopped. Proceeding with state transition.");
}
}
// Fire StoppingCompleted trigger (always proceed, even if some tasks didn't stop)
_stateMachine.Fire(TaskManagerTrigger.StoppingCompleted);
});
}
/// <summary>
/// Gets a task by name.
/// </summary>
public ScriptTask? GetTask(string name)
{
_tasks.TryGetValue(name, out var task);
return task;
}
/// <summary>
/// Resets all tasks. Clears all tasks and disposes them.
/// Only allowed when state is Idle.
/// </summary>
public MessageResult Reset()
{
try
{
lock (_stateLockObject)
{
if (_currentState != TaskManagerState.Idle)
return new MessageResult(false, $"Cannot reset tasks when TaskManager is in state: {_currentState}");
}
lock (_lockObject)
{
var count = _tasks.Count;
foreach (var task in _tasks.Values)
{
try
{
task.Dispose();
}
catch
{
// Ignore disposal errors
}
}
_tasks.Clear();
return new MessageResult(true, $"Reset {count} task(s) successfully");
}
}
catch (Exception ex)
{
return new MessageResult(false, $"Failed to reset tasks: {ex.Message}");
}
}
/// <summary>
/// Loads all tasks from a collection of ScriptTaskModel. This clears existing tasks first.
/// Only allowed when state is Idle.
/// </summary>
/// <param name="taskModels">The collection of task models to load.</param>
public MessageResult Load(IEnumerable<ScriptTaskModel> taskModels)
{
try
{
ArgumentNullException.ThrowIfNull(taskModels);
lock (_stateLockObject)
{
if (_currentState != TaskManagerState.Idle)
return new MessageResult(false, $"Cannot load tasks when TaskManager is in state: {_currentState}");
}
// Reset existing tasks first
var resetResult = Reset();
if (!resetResult.IsSuccess)
return resetResult;
// Load all tasks
lock (_lockObject)
{
var loadedCount = 0;
var errorCount = 0;
var errors = new List<string>();
foreach (var model in taskModels)
{
if (string.IsNullOrWhiteSpace(model.Name))
continue;
try
{
// Create LoggerTask with task name
var loggerTask = new LoggerTask(model.Name, _consoleHubContext);
// Create ScriptEngineGlobals with LoggerTask
var scriptEngineGlobals = new ScriptEngineGlobals(loggerTask, _scopeFactory);
var robotNetDict = ScriptHelper.ConvertGlobalsToDictionary(scriptEngineGlobals, typeof(IScriptGlobals));
var appApisDict = _scriptResource.GetTaskGlobals();
var globalVariablesDict = _variableManager.Globals;
var missionParametersDict = new Dictionary<string, object?>();
var globals = new ScriptGlobals(
robotNetDict,
appApisDict,
globalVariablesDict,
missionParametersDict
);
var task = new ScriptTask(model, globals);
_tasks.TryAdd(model.Name, task);
loadedCount++;
}
catch (Exception ex)
{
errorCount++;
errors.Add($"Failed to load task '{model.Name}': {ex.Message}");
}
}
if (errorCount > 0)
{
return new MessageResult(false,
$"Loaded {loadedCount} task(s) successfully, {errorCount} failed. Errors: {string.Join("; ", errors)}");
}
return new MessageResult(true, $"Loaded {loadedCount} task(s) successfully");
}
}
catch (Exception ex)
{
return new MessageResult(false, $"Failed to load tasks: {ex.Message}");
}
}
/// <summary>
/// Starts the TaskManager. This will start all tasks with AutoStart == true.
/// </summary>
public MessageResult Start()
{
try
{
_stateMachine.Fire(TaskManagerTrigger.Start);
return new MessageResult(true, "TaskManager started successfully");
}
catch (Exception ex)
{
return new MessageResult(false, $"Failed to start TaskManager: {ex.Message}");
}
}
/// <summary>
/// Stops the TaskManager. This will ensure all tasks are stopped.
/// </summary>
public MessageResult Stop()
{
try
{
_stateMachine.Fire(TaskManagerTrigger.Stop);
return new MessageResult(true, "TaskManager stop initiated");
}
catch (Exception ex)
{
return new MessageResult(false, $"Failed to stop TaskManager: {ex.Message}");
}
}
/// <summary>
/// Waits for a task to reach Stopped state.
/// </summary>
private void WaitForTaskStopped(ScriptTask task, int timeoutMs = 5000)
{
var startTime = DateTime.UtcNow;
// Only wait if task is actually executing - if not executing, wait for state machine transition
while (task.IsExecuting && (DateTime.UtcNow - startTime).TotalMilliseconds < timeoutMs)
{
// Check if state changed to stopped/error/idle
if (task.State == ScriptTaskState.Stopped ||
task.State == ScriptTaskState.Idle ||
task.State == ScriptTaskState.Error)
{
// Task stopped, but wait a bit more to ensure IsExecuting is reset
Thread.Sleep(100);
if (!task.IsExecuting)
{
return; // Task fully stopped
}
}
Thread.Sleep(50); // Check every 50ms
}
// If task is not executing, wait a bit for state machine to transition
if (!task.IsExecuting)
{
// Wait up to 500ms for state machine transition to complete
var stateTransitionTimeout = 500;
var stateStartTime = DateTime.UtcNow;
while ((task.State == ScriptTaskState.Stopping || task.State == ScriptTaskState.Pausing) &&
(DateTime.UtcNow - stateStartTime).TotalMilliseconds < stateTransitionTimeout)
{
Thread.Sleep(50);
// Check if state transitioned
if (task.State == ScriptTaskState.Stopped ||
task.State == ScriptTaskState.Idle ||
task.State == ScriptTaskState.Error)
{
return; // State machine transitioned successfully
}
}
// If still in Stopping/Pausing state after waiting, it's a state machine issue
// But task is not executing, so it's effectively stopped - proceed anyway
if (task.State == ScriptTaskState.Stopping || task.State == ScriptTaskState.Pausing)
{
// Don't log warning - this is expected if state machine is slow
// The task is effectively stopped (not executing)
return;
}
return; // Task is not executing, proceed
}
// Timeout - task is still executing (ScriptRunner blocking)
_logger.LogWarning($"Task '{task.Name}' is still executing after {timeoutMs}ms timeout. ScriptRunner may be blocking.");
_consoleHubContext.LogWarningToTask(task.Name, $"Task is still executing after {timeoutMs}ms timeout. ScriptRunner may be blocking.");
}
/// <summary>
/// Pauses a specific task by name.
/// </summary>
public MessageResult PauseTask(string name)
{
try
{
if (string.IsNullOrWhiteSpace(name))
return new MessageResult(false, "Task name cannot be null or empty");
if (!_tasks.TryGetValue(name, out var task))
return new MessageResult(false, $"Task '{name}' not found");
if (task.State != ScriptTaskState.Running)
return new MessageResult(false, $"Task '{name}' is not in Running state (current state: {task.State})");
task.Pause();
return new MessageResult(true, $"Task '{name}' paused successfully");
}
catch (Exception ex)
{
return new MessageResult(false, $"Failed to pause task '{name}': {ex.Message}");
}
}
/// <summary>
/// Gets all tasks as ScriptTaskDto array.
/// </summary>
public ScriptTaskDto[] GetScriptTasks()
{
lock (_lockObject)
{
return [.. _tasks.Values.Select(t => new ScriptTaskDto(
t.Name,
t.Interval,
t.State == ScriptTaskState.Running,
t.ExecutionCount))];
}
}
/// <summary>
/// Finds specific tasks by names as ScriptTaskDto array.
/// </summary>
public ScriptTaskDto[] FindScriptTasks(string[] names)
{
ArgumentNullException.ThrowIfNull(names);
lock (_lockObject)
{
return [.. _tasks.Values
.Where(t => names.Contains(t.Name))
.Select(t => new ScriptTaskDto(
t.Name,
t.Interval,
t.State == ScriptTaskState.Running,
t.ExecutionCount))];
}
}
/// <summary>
/// Enables a task (resumes if paused, starts if stopped).
/// </summary>
public MessageResult EnableTask(string name)
{
try
{
if (string.IsNullOrWhiteSpace(name))
return new MessageResult(false, "Task name cannot be null or empty");
if (!_tasks.TryGetValue(name, out var task))
return new MessageResult(false, $"Task '{name}' not found");
if (task.State == ScriptTaskState.Paused)
{
task.Resume();
return new MessageResult(true, $"Task '{name}' resumed successfully");
}
else if (task.State == ScriptTaskState.Stopped || task.State == ScriptTaskState.Idle)
{
task.Start();
return new MessageResult(true, $"Task '{name}' started successfully");
}
else if (task.State == ScriptTaskState.Running)
{
return new MessageResult(true, $"Task '{name}' is already running");
}
else
{
return new MessageResult(false, $"Task '{name}' cannot be enabled from state: {task.State}");
}
}
catch (Exception ex)
{
return new MessageResult(false, $"Failed to enable task '{name}': {ex.Message}");
}
}
/// <summary>
/// Disables a task (pauses if running).
/// </summary>
public MessageResult DisableTask(string name)
{
return PauseTask(name);
}
/// <summary>
/// Resumes a specific task by name. Only works for tasks in Paused or Idle state.
/// </summary>
public MessageResult ResumeTask(string name)
{
try
{
if (string.IsNullOrWhiteSpace(name))
return new MessageResult(false, "Task name cannot be null or empty");
if (!_tasks.TryGetValue(name, out var task))
return new MessageResult(false, $"Task '{name}' not found");
if (task.State == ScriptTaskState.Paused)
{
// Resume paused task
task.Resume();
return new MessageResult(true, $"Task '{name}' resumed successfully");
}
else if (task.State == ScriptTaskState.Idle)
{
// Start idle task
task.Start();
return new MessageResult(true, $"Task '{name}' started successfully");
}
else
{
return new MessageResult(false, $"Task '{name}' cannot be resumed from state: {task.State}. Only Paused or Idle states are allowed.");
}
}
catch (Exception ex)
{
return new MessageResult(false, $"Failed to resume task '{name}': {ex.Message}");
}
}
/// <summary>
/// Checks if all tasks are stopped.
/// </summary>
public bool AreAllTasksStopped()
{
lock (_lockObject)
{
return _tasks.Values.All(t => t.State == ScriptTaskState.Stopped || t.State == ScriptTaskState.Error);
}
}
/// <summary>
/// Gets the count of tasks.
/// </summary>
public int Count => _tasks.Count;
/// <summary>
/// Disposes the TaskManager and all tasks.
/// </summary>
public void Dispose()
{
if (_disposed)
return;
// Stop state machine first
try
{
_stateMachine.Stop();
}
catch
{
// Ignore errors when stopping state machine
}
lock (_lockObject)
{
foreach (var task in _tasks.Values)
{
try
{
task.Dispose();
}
catch
{
// Ignore disposal errors
}
}
_tasks.Clear();
}
_disposed = true;
GC.SuppressFinalize(this);
}
}

View File

@@ -0,0 +1,258 @@
using System.Collections.Concurrent;
using RobotNet10.ScriptEngine.Models;
using RobotNet10.ScriptEngine.Shared;
using RobotNet10.Shared;
namespace RobotNet10.ScriptEngine;
/// <summary>
/// Manages global variables shared across all scripts in ScriptEngine.
/// Variables are stored in a thread-safe ConcurrentDictionary and are runtime-only (not persisted to database).
/// </summary>
public class VariableManager
{
/// <summary>
/// Gets the underlying dictionary of variables. This is used to pass to ScriptGlobals.
/// </summary>
public IDictionary<string, object?> Globals => _globals;
private readonly ConcurrentDictionary<string, object?> _globals = [];
private readonly ConcurrentDictionary<string, ScriptVariableModel> _models = [];
/// <summary>
/// Gets all variable models.
/// </summary>
public IReadOnlyDictionary<string, ScriptVariableModel> Models => _models;
/// <summary>
/// Resets all variables and models. Clears both the globals dictionary and models dictionary.
/// </summary>
public void Reset()
{
_globals.Clear();
_models.Clear();
}
/// <summary>
/// Loads variables from a collection of ScriptVariableModel. This clears existing variables and models first.
/// </summary>
/// <param name="variables">The collection of variable models to load.</param>
public void Load(IEnumerable<ScriptVariableModel> variables)
{
if (variables == null)
throw new ArgumentNullException(nameof(variables));
_globals.Clear();
_models.Clear();
foreach (var variable in variables)
{
if (string.IsNullOrWhiteSpace(variable.Name))
continue;
// Lưu metadata cho biến
// Store metadata for variable
_models.TryAdd(variable.Name, new ScriptVariableModel(
variable.Name,
variable.Type,
variable.DefaultValue,
variable.PublicRead,
variable.PublicWrite));
// Khởi tạo biến với giá trị mặc định
// Initialize variable with default value
_globals.TryAdd(variable.Name, variable.DefaultValue);
}
}
/// <summary>
/// Gets all variables that are marked as PublicRead, converted to ScriptVariableDto for UI display.
/// </summary>
/// <returns>A collection of ScriptVariableDto representing all public-readable variables.</returns>
public IEnumerable<ScriptVariableDto> GetVariables()
{
return _models.Values
.Where(variable => variable.PublicRead)
.Select(variable => new ScriptVariableDto(
variable.Name,
variable.TypeName,
_globals.TryGetValue(variable.Name, out var value) ? (value?.ToString() ?? "null") : "null",
variable.PublicWrite));
}
/// <summary>
/// Gets specific variables by names that are marked as PublicRead, converted to ScriptVariableDto for UI display.
/// </summary>
/// <param name="names">The names of variables to retrieve.</param>
/// <returns>A collection of ScriptVariableDto representing the requested public-readable variables.</returns>
public IEnumerable<ScriptVariableDto> GetVariables(string[] names)
{
if (names == null)
throw new ArgumentNullException(nameof(names));
return _models.Values
.Where(variable => variable.PublicRead && names.Contains(variable.Name))
.Select(variable => new ScriptVariableDto(
variable.Name,
variable.TypeName,
_globals.TryGetValue(variable.Name, out var value) ? (value?.ToString() ?? "null") : "null",
variable.PublicWrite));
}
/// <summary>
/// Gets the value of a variable by name.
/// </summary>
/// <param name="name">The name of the variable.</param>
/// <returns>The value of the variable, or null if not found.</returns>
public object? GetVariable(string name)
{
if (string.IsNullOrWhiteSpace(name))
throw new ArgumentException("Variable name cannot be null or empty", nameof(name));
_globals.TryGetValue(name, out var value);
return value;
}
/// <summary>
/// Gets the value of a variable by name with type conversion.
/// </summary>
/// <typeparam name="T">The type to convert to.</typeparam>
/// <param name="name">The name of the variable.</param>
/// <returns>The value of the variable converted to type T, or default(T) if not found.</returns>
public T? GetVariable<T>(string name)
{
var value = GetVariable(name);
if (value == null)
return default;
if (value is T typedValue)
return typedValue;
try
{
return (T)Convert.ChangeType(value, typeof(T));
}
catch
{
return default;
}
}
/// <summary>
/// Sets the value of a variable by name from a string value. Only variables marked as PublicWrite can be set.
/// </summary>
/// <param name="name">The name of the variable.</param>
/// <param name="value">The string value to set (will be converted to the variable's type).</param>
/// <returns>MessageResult indicating success or failure.</returns>
public MessageResult SetValue(string name, string value)
{
if (string.IsNullOrWhiteSpace(name))
return new MessageResult(false, "Variable name cannot be null or empty");
if (!_models.TryGetValue(name, out var metadata))
return new MessageResult(false, $"Variable '{name}' not found");
if (!metadata.PublicWrite)
return new MessageResult(false, $"Variable '{name}' is not writable");
try
{
object? convertedValue = null;
if (!string.IsNullOrEmpty(value) && value != "null")
{
if (metadata.Type == typeof(string))
{
convertedValue = value;
}
else if (metadata.Type.IsEnum)
{
convertedValue = Enum.Parse(metadata.Type, value, true);
}
else
{
convertedValue = Convert.ChangeType(value, metadata.Type);
}
}
if (SetVariable(name, convertedValue, false))
{
return new MessageResult(true, $"Variable '{name}' set successfully");
}
else
{
return new MessageResult(false, $"Failed to set variable '{name}'");
}
}
catch (Exception ex)
{
return new MessageResult(false, $"Failed to set variable '{name}': {ex.Message}");
}
}
/// <summary>
/// Sets the value of a variable by name. Only variables marked as PublicWrite can be set from outside scripts.
/// </summary>
/// <param name="name">The name of the variable.</param>
/// <param name="value">The value to set.</param>
/// <param name="force">If true, allows setting even if PublicWrite is false (for internal use).</param>
/// <returns>True if the variable was set successfully, false otherwise.</returns>
public bool SetVariable(string name, object? value, bool force = false)
{
if (string.IsNullOrWhiteSpace(name))
throw new ArgumentException("Variable name cannot be null or empty", nameof(name));
// Check if variable exists and if it's writable (unless force is true)
if (_models.TryGetValue(name, out var metadata))
{
if (!force && !metadata.PublicWrite)
return false; // Variable exists but is not writable
// Validate type if metadata exists
if (value != null && !metadata.Type.IsInstanceOfType(value))
{
try
{
value = Convert.ChangeType(value, metadata.Type);
}
catch
{
return false; // Type conversion failed
}
}
}
_globals.AddOrUpdate(name, value, (key, oldValue) => value);
return true;
}
/// <summary>
/// Checks if a variable exists.
/// </summary>
/// <param name="name">The name of the variable.</param>
/// <returns>True if the variable exists, false otherwise.</returns>
public bool HasVariable(string name)
{
if (string.IsNullOrWhiteSpace(name))
return false;
return _globals.ContainsKey(name);
}
/// <summary>
/// Gets all variable names.
/// </summary>
/// <returns>A collection of all variable names.</returns>
public IEnumerable<string> GetVariableNames()
{
return _globals.Keys;
}
/// <summary>
/// Gets all variables as a read-only dictionary snapshot.
/// </summary>
/// <returns>A read-only dictionary containing all variables.</returns>
public IReadOnlyDictionary<string, object?> GetAllVariables()
{
return new Dictionary<string, object?>(_globals);
}
}