Initial commit
This commit is contained in:
@@ -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; }
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
412
srcs/RobotNet10/Commons/RobotNet10.ScriptEngine/FileManager.cs
Normal file
412
srcs/RobotNet10/Commons/RobotNet10.ScriptEngine/FileManager.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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}");
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
);
|
||||
@@ -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
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
namespace RobotNet10.ScriptEngine.Models;
|
||||
|
||||
public record ScriptMissionParameterModel(string Name, Type Type, object? DefaultValue = null);
|
||||
@@ -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
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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>
|
||||
645
srcs/RobotNet10/Commons/RobotNet10.ScriptEngine/ScriptEngine.cs
Normal file
645
srcs/RobotNet10/Commons/RobotNet10.ScriptEngine/ScriptEngine.cs
Normal 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
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
607
srcs/RobotNet10/Commons/RobotNet10.ScriptEngine/TaskManager.cs
Normal file
607
srcs/RobotNet10/Commons/RobotNet10.ScriptEngine/TaskManager.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user