Initial commit
This commit is contained in:
@@ -0,0 +1,369 @@
|
||||
using RobotNet10.CustomConfiguration.Events;
|
||||
using RobotNet10.CustomConfiguration.Models;
|
||||
using RobotNet10.CustomConfiguration.Services;
|
||||
using RobotNet10.MqttConnection;
|
||||
using RobotNet10.RobotApp.Services.Robot.Connection.Models;
|
||||
using System.Reflection;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace RobotNet10.RobotApp.Services.ConfigManager;
|
||||
|
||||
/// <summary>
|
||||
/// Service implementation for managing application configurations
|
||||
/// </summary>
|
||||
public class ConnectionConfig : IConnectionConfig
|
||||
{
|
||||
private readonly IConfigManager _configManager;
|
||||
private readonly Logger<ConnectionConfig> _logger;
|
||||
|
||||
private readonly Lock _lockObject = new();
|
||||
private VDA5050ProtocolConfig? _vda5050Config;
|
||||
private MQTTConfig? _mqttConfig;
|
||||
private bool _vda5050ConfigLoaded = false;
|
||||
private bool _mqttConfigLoaded = false;
|
||||
|
||||
private const string VDA5050_PROTOCOL_CONFIG_TYPE = "VDA5050ProtocolConfig";
|
||||
private const string MQTT_CONFIG_TYPE = "MQTTConfig";
|
||||
private const string VDA5050_CONFIG_FILE_NAME = "VDA5050ProtocolConfig.config.json";
|
||||
private const string MQTT_CONFIG_FILE_NAME = "MQTTConfig.config.json";
|
||||
|
||||
public ConnectionConfig(IConfigManager configManager, Logger<ConnectionConfig> logger)
|
||||
{
|
||||
_configManager = configManager ?? throw new ArgumentNullException(nameof(configManager));
|
||||
_logger = logger;
|
||||
|
||||
// Subscribe to config changes
|
||||
_configManager.ConfigChanged += OnConfigChanged;
|
||||
}
|
||||
|
||||
public VDA5050ProtocolConfig GetVDA5050Config()
|
||||
{
|
||||
if (!_vda5050ConfigLoaded)
|
||||
{
|
||||
lock (_lockObject)
|
||||
{
|
||||
if (!_vda5050ConfigLoaded)
|
||||
{
|
||||
LoadVDA5050ConfigAsync().GetAwaiter().GetResult();
|
||||
}
|
||||
}
|
||||
}
|
||||
return _vda5050Config ?? throw new InvalidOperationException("VDA5050 configuration not loaded");
|
||||
}
|
||||
|
||||
public MQTTConfig GetMqttConfig()
|
||||
{
|
||||
if (!_mqttConfigLoaded)
|
||||
{
|
||||
lock (_lockObject)
|
||||
{
|
||||
if (!_mqttConfigLoaded)
|
||||
{
|
||||
LoadMqttConfigAsync().GetAwaiter().GetResult();
|
||||
}
|
||||
}
|
||||
}
|
||||
return _mqttConfig ?? throw new InvalidOperationException("MQTT configuration not loaded");
|
||||
}
|
||||
|
||||
private async Task LoadVDA5050ConfigAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
var configFile = await _configManager.GetConfigByTypeAsync(VDA5050_PROTOCOL_CONFIG_TYPE);
|
||||
|
||||
if (configFile == null)
|
||||
{
|
||||
if (!TryLoadVda5050ConfigFromLocalFile(out var vdaConfig))
|
||||
{
|
||||
_logger.Warning("VDA5050 Protocol configuration not found, using defaults");
|
||||
_vda5050Config = new VDA5050ProtocolConfig
|
||||
{
|
||||
Manufacturer = "RobotNet",
|
||||
Version = "2.1.0",
|
||||
TopicPrefix = "uagv/v2",
|
||||
SerialNumber = "Robot-Dev"
|
||||
};
|
||||
}
|
||||
else
|
||||
{
|
||||
_vda5050Config = vdaConfig;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
_vda5050Config = MapConfigVariablesToObject<VDA5050ProtocolConfig>(configFile.Variables);
|
||||
_logger.Info("VDA5050 Protocol configuration loaded successfully");
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Error($"Error loading VDA5050 Protocol configuration, using defaults: {ex.Message}");
|
||||
_vda5050Config = new VDA5050ProtocolConfig
|
||||
{
|
||||
Manufacturer = "RobotNet",
|
||||
Version = "2.1.0",
|
||||
TopicPrefix = "uagv/v2"
|
||||
};
|
||||
}
|
||||
finally
|
||||
{
|
||||
_vda5050ConfigLoaded = true;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task LoadMqttConfigAsync()
|
||||
{
|
||||
var configFile = await _configManager.GetConfigByTypeAsync(MQTT_CONFIG_TYPE);
|
||||
if (configFile is not null)
|
||||
{
|
||||
_mqttConfig = MapConfigVariablesToObject<MQTTConfig>(configFile.Variables);
|
||||
}
|
||||
else if (TryLoadMqttConfigFromLocalFile(out var localMqttConfig))
|
||||
{
|
||||
_mqttConfig = localMqttConfig;
|
||||
}
|
||||
else
|
||||
{
|
||||
throw new InvalidOperationException($"MQTT configuration (ConfigType: {MQTT_CONFIG_TYPE}) not found");
|
||||
}
|
||||
|
||||
if (string.IsNullOrEmpty(_mqttConfig?.Host))
|
||||
{
|
||||
throw new InvalidOperationException("MQTT configuration: Host is required");
|
||||
}
|
||||
|
||||
if (string.IsNullOrEmpty(_mqttConfig.ClientId))
|
||||
{
|
||||
throw new InvalidOperationException("MQTT configuration: ClientId is required");
|
||||
}
|
||||
|
||||
_mqttConfigLoaded = true;
|
||||
_logger.Info("MQTT configuration loaded successfully");
|
||||
}
|
||||
|
||||
private bool TryLoadVda5050ConfigFromLocalFile(out VDA5050ProtocolConfig config)
|
||||
{
|
||||
config = new VDA5050ProtocolConfig();
|
||||
foreach (var path in GetLocalConfigCandidates(VDA5050_CONFIG_FILE_NAME))
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!File.Exists(path))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
using var doc = JsonDocument.Parse(File.ReadAllText(path));
|
||||
config = new VDA5050ProtocolConfig
|
||||
{
|
||||
Manufacturer = GetStringVariable(doc.RootElement, "Manufacturer", "RobotNet"),
|
||||
Version = GetStringVariable(doc.RootElement, "Version", "2.1.0"),
|
||||
TopicPrefix = GetStringVariable(doc.RootElement, "TopicPrefix", "uagv/v2"),
|
||||
SerialNumber = GetStringVariable(doc.RootElement, "SerialNumber", "Robot-Dev")
|
||||
};
|
||||
|
||||
_logger.Info($"VDA5050 Protocol configuration loaded from local file: {path}");
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Warning($"Failed reading VDA5050 config file '{path}': {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private bool TryLoadMqttConfigFromLocalFile(out MQTTConfig config)
|
||||
{
|
||||
config = new MQTTConfig();
|
||||
foreach (var path in GetLocalConfigCandidates(MQTT_CONFIG_FILE_NAME))
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!File.Exists(path))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
using var doc = JsonDocument.Parse(File.ReadAllText(path));
|
||||
config = new MQTTConfig
|
||||
{
|
||||
Host = GetStringVariable(doc.RootElement, "Host", string.Empty),
|
||||
Port = GetIntVariable(doc.RootElement, "Port", 1883),
|
||||
ClientId = GetStringVariable(doc.RootElement, "ClientId", string.Empty),
|
||||
Username = GetStringVariable(doc.RootElement, "Username", string.Empty),
|
||||
Password = GetStringVariable(doc.RootElement, "Password", string.Empty),
|
||||
EnablePassword = GetBoolVariable(doc.RootElement, "EnablePassword", false),
|
||||
EnableTls = GetBoolVariable(doc.RootElement, "EnableTls", false),
|
||||
EnableCA = GetBoolVariable(doc.RootElement, "EnableCA", false),
|
||||
CaCertificatesPath = GetStringVariable(doc.RootElement, "CaCertificatesPath", string.Empty),
|
||||
ClientCertificatePath = GetStringVariable(doc.RootElement, "ClientCertificatePath", string.Empty),
|
||||
ClientKeyPath = GetStringVariable(doc.RootElement, "ClientKeyPath", string.Empty),
|
||||
PublishRepeat = GetIntVariable(doc.RootElement, "PublishRepeat", 2)
|
||||
};
|
||||
|
||||
_logger.Info($"MQTT configuration loaded from local file: {path}");
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Warning($"Failed reading MQTT config file '{path}': {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static IEnumerable<string> GetLocalConfigCandidates(string fileName)
|
||||
{
|
||||
yield return Path.Combine(Directory.GetCurrentDirectory(), "RobotAppCustomConfigs", "configs", fileName);
|
||||
yield return Path.Combine(AppContext.BaseDirectory, "RobotAppCustomConfigs", "configs", fileName);
|
||||
}
|
||||
|
||||
private static string GetStringVariable(JsonElement root, string variableName, string fallback)
|
||||
{
|
||||
if (!root.TryGetProperty("variables", out var variables) || variables.ValueKind != JsonValueKind.Array)
|
||||
{
|
||||
return fallback;
|
||||
}
|
||||
|
||||
foreach (var variable in variables.EnumerateArray())
|
||||
{
|
||||
if (!variable.TryGetProperty("name", out var nameElement) || !string.Equals(nameElement.GetString(), variableName, StringComparison.Ordinal))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (variable.TryGetProperty("value", out var valueElement) && valueElement.ValueKind != JsonValueKind.Null)
|
||||
{
|
||||
return valueElement.ToString() ?? fallback;
|
||||
}
|
||||
}
|
||||
|
||||
return fallback;
|
||||
}
|
||||
|
||||
private static int GetIntVariable(JsonElement root, string variableName, int fallback)
|
||||
{
|
||||
var rawValue = GetStringVariable(root, variableName, fallback.ToString());
|
||||
return int.TryParse(rawValue, out var value) ? value : fallback;
|
||||
}
|
||||
|
||||
private static bool GetBoolVariable(JsonElement root, string variableName, bool fallback)
|
||||
{
|
||||
var rawValue = GetStringVariable(root, variableName, fallback.ToString());
|
||||
return bool.TryParse(rawValue, out var value) ? value : fallback;
|
||||
}
|
||||
|
||||
private void OnConfigChanged(object? sender, ConfigChangedEventArgs e)
|
||||
{
|
||||
// Reload config if it's one of our config types
|
||||
if (e.ConfigType == VDA5050_PROTOCOL_CONFIG_TYPE)
|
||||
{
|
||||
lock (_lockObject)
|
||||
{
|
||||
_vda5050ConfigLoaded = false;
|
||||
_vda5050Config = null;
|
||||
}
|
||||
_logger.Info("VDA5050 Protocol configuration changed, will reload on next access");
|
||||
}
|
||||
else if (e.ConfigType == MQTT_CONFIG_TYPE)
|
||||
{
|
||||
lock (_lockObject)
|
||||
{
|
||||
_mqttConfigLoaded = false;
|
||||
_mqttConfig = null;
|
||||
}
|
||||
_logger.Info("MQTT configuration changed, will reload on next access");
|
||||
}
|
||||
}
|
||||
|
||||
private T MapConfigVariablesToObject<T>(List<ConfigVariable> variables) where T : new()
|
||||
{
|
||||
var obj = new T();
|
||||
var properties = typeof(T).GetProperties(BindingFlags.Public | BindingFlags.Instance | BindingFlags.SetProperty);
|
||||
|
||||
foreach (var property in properties)
|
||||
{
|
||||
// Find variable by exact name match (case-sensitive)
|
||||
var variable = variables.FirstOrDefault(v => v.Name == property.Name);
|
||||
|
||||
if (variable != null && variable.Value != null)
|
||||
{
|
||||
try
|
||||
{
|
||||
var convertedValue = ConvertValue(variable.Value, property.PropertyType, variable.Type);
|
||||
property.SetValue(obj, convertedValue);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Warning($"Failed to set property {property.Name} from variable {variable.Name}: {ex.Message}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return obj;
|
||||
}
|
||||
|
||||
private object? ConvertValue(object? value, Type targetType, ConfigVariableType variableType)
|
||||
{
|
||||
if (value == null)
|
||||
return targetType.IsValueType ? Activator.CreateInstance(targetType) : null;
|
||||
|
||||
// If value is already of the correct type, return it
|
||||
if (targetType.IsInstanceOfType(value))
|
||||
return value;
|
||||
|
||||
try
|
||||
{
|
||||
// Handle nullable types
|
||||
var underlyingType = Nullable.GetUnderlyingType(targetType) ?? targetType;
|
||||
|
||||
// Convert based on target type
|
||||
if (underlyingType == typeof(string))
|
||||
{
|
||||
return value.ToString() ?? string.Empty;
|
||||
}
|
||||
else if (underlyingType == typeof(int))
|
||||
{
|
||||
if (value is int i) return i;
|
||||
if (int.TryParse(value.ToString(), out var parsed)) return parsed;
|
||||
if (value is double d) return (int)d;
|
||||
throw new InvalidCastException($"Cannot convert {value.GetType()} to int");
|
||||
}
|
||||
else if (underlyingType == typeof(double))
|
||||
{
|
||||
if (value is double d) return d;
|
||||
if (double.TryParse(value.ToString(), out var parsed)) return parsed;
|
||||
if (value is int i) return i;
|
||||
throw new InvalidCastException($"Cannot convert {value.GetType()} to double");
|
||||
}
|
||||
else if (underlyingType == typeof(bool))
|
||||
{
|
||||
if (value is bool b) return b;
|
||||
if (bool.TryParse(value.ToString(), out var parsed)) return parsed;
|
||||
// Handle numeric values: 0/1, "0"/"1", etc.
|
||||
if (value.ToString()?.Trim() == "0" || value.ToString()?.Trim().ToLower() == "false") return false;
|
||||
if (value.ToString()?.Trim() == "1" || value.ToString()?.Trim().ToLower() == "true") return true;
|
||||
throw new InvalidCastException($"Cannot convert {value.GetType()} to bool");
|
||||
}
|
||||
else if (underlyingType.IsEnum)
|
||||
{
|
||||
var enumValue = Enum.Parse(underlyingType, value.ToString() ?? "", true);
|
||||
return enumValue;
|
||||
}
|
||||
else
|
||||
{
|
||||
// Try standard conversion
|
||||
return Convert.ChangeType(value, underlyingType);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Warning($"Error converting value {value} of type {value.GetType()} to {targetType}: {ex.Message}");
|
||||
throw;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
using RobotNet10.MqttConnection;
|
||||
using RobotNet10.RobotApp.Services.Robot.Connection.Models;
|
||||
|
||||
namespace RobotNet10.RobotApp.Services.ConfigManager;
|
||||
|
||||
/// <summary>
|
||||
/// Service for managing application configurations
|
||||
/// </summary>
|
||||
public interface IConnectionConfig
|
||||
{
|
||||
/// <summary>
|
||||
/// Get VDA5050 Protocol configuration
|
||||
/// </summary>
|
||||
VDA5050ProtocolConfig GetVDA5050Config();
|
||||
|
||||
/// <summary>
|
||||
/// Get MQTT configuration
|
||||
/// </summary>
|
||||
MQTTConfig GetMqttConfig();
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
using RobotNet10.RobotApp.Services.Navigation.CSharp;
|
||||
|
||||
namespace RobotNet10.RobotApp.Services.ConfigManager;
|
||||
public interface INavigationConfig
|
||||
{
|
||||
PurePursuitConfig GetPurepursuitConfig();
|
||||
VelocitySignalProcessingConfig GetVelocitySignalProcessingConfig();
|
||||
VelocityEstimatorConfig GetVelocityEstimatorConfig();
|
||||
MotorDynamicsConfig GetMotorDynamicsConfig();
|
||||
PIDConfig GetMovePidConfig();
|
||||
PIDConfig GetRotatePidConfig();
|
||||
Navigation.NavigationConfig GetNavigationConfig();
|
||||
NavigationTune.Shared.Models.StanleyConfig GetStanleyConig();
|
||||
DockToConfig GetDockToConfig();
|
||||
DockToConfig GetMoveStraightConfig();
|
||||
LocalPlannerConfig GetLocalPlannerConfig();
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
using RobotNet10.RobotApp.Services.Robot;
|
||||
using RobotNet10.RobotApp.Services.Simulation;
|
||||
|
||||
namespace RobotNet10.RobotApp.Services.ConfigManager;
|
||||
|
||||
/// <summary>
|
||||
/// Service for managing robot configurations
|
||||
/// </summary>
|
||||
public interface IRobotConfiguration
|
||||
{
|
||||
/// <summary>
|
||||
/// Get Robot Physical configuration
|
||||
/// </summary>
|
||||
RobotPhysicalConfig GetRobotPhysicalConfig();
|
||||
|
||||
/// <summary>
|
||||
/// Get Simulation configuration
|
||||
/// </summary>
|
||||
SimulationConfig GetSimulationConfig();
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,456 @@
|
||||
using RobotNet10.CustomConfiguration.Events;
|
||||
using RobotNet10.CustomConfiguration.Models;
|
||||
using RobotNet10.CustomConfiguration.Services;
|
||||
using RobotNet10.RobotApp.Services.Robot;
|
||||
using RobotNet10.RobotApp.Services.Simulation;
|
||||
using RobotNet10.RobotApp.Shared.Enums;
|
||||
using System.Reflection;
|
||||
|
||||
namespace RobotNet10.RobotApp.Services.ConfigManager;
|
||||
|
||||
/// <summary>
|
||||
/// Service implementation for managing robot configurations
|
||||
/// </summary>
|
||||
public class RobotConfiguration : IRobotConfiguration
|
||||
{
|
||||
private readonly IConfigManager _configManager;
|
||||
private readonly Logger<RobotConfiguration> _logger;
|
||||
|
||||
private readonly Lock _lockObject = new();
|
||||
private RobotPhysicalConfig? _robotPhysicalConfig;
|
||||
private SimulationConfig? _simulationConfig;
|
||||
private bool _robotPhysicalConfigLoaded = false;
|
||||
private bool _simulationConfigLoaded = false;
|
||||
|
||||
private const string ROBOT_PHYSICAL_CONFIG_TYPE = "RobotPhysicalConfig";
|
||||
private const string SIMULATION_CONFIG_TYPE = "SimulationConfig";
|
||||
|
||||
public RobotConfiguration(IConfigManager configManager, Logger<RobotConfiguration> logger)
|
||||
{
|
||||
_configManager = configManager ?? throw new ArgumentNullException(nameof(configManager));
|
||||
_logger = logger;
|
||||
|
||||
// Subscribe to config changes
|
||||
_configManager.ConfigChanged += OnConfigChanged;
|
||||
}
|
||||
|
||||
public RobotPhysicalConfig GetRobotPhysicalConfig()
|
||||
{
|
||||
if (!_robotPhysicalConfigLoaded)
|
||||
{
|
||||
lock (_lockObject)
|
||||
{
|
||||
if (!_robotPhysicalConfigLoaded)
|
||||
{
|
||||
LoadRobotPhysicalConfigAsync().GetAwaiter().GetResult();
|
||||
}
|
||||
}
|
||||
}
|
||||
return _robotPhysicalConfig ?? throw new InvalidOperationException("Robot Physical configuration not loaded");
|
||||
}
|
||||
|
||||
public SimulationConfig GetSimulationConfig()
|
||||
{
|
||||
if (!_simulationConfigLoaded)
|
||||
{
|
||||
lock (_lockObject)
|
||||
{
|
||||
if (!_simulationConfigLoaded)
|
||||
{
|
||||
LoadSimulationConfigAsync().GetAwaiter().GetResult();
|
||||
}
|
||||
}
|
||||
}
|
||||
return _simulationConfig ?? throw new InvalidOperationException("Simulation configuration not loaded");
|
||||
}
|
||||
|
||||
private async Task LoadRobotPhysicalConfigAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
var configFile = await _configManager.GetConfigByTypeAsync(ROBOT_PHYSICAL_CONFIG_TYPE);
|
||||
|
||||
if (configFile == null)
|
||||
{
|
||||
_logger.Warning("Robot Physical configuration not found, using defaults");
|
||||
_robotPhysicalConfig = new RobotPhysicalConfig
|
||||
{
|
||||
WheelRadius = 0.1,
|
||||
WheelBase = 0.6,
|
||||
Width = 0.606,
|
||||
Length = 1.106,
|
||||
Height = 0.5,
|
||||
NavigationType = NavigationType.Differential
|
||||
};
|
||||
}
|
||||
else
|
||||
{
|
||||
_robotPhysicalConfig = MapConfigVariablesToRobotPhysicalConfig(configFile.Variables);
|
||||
_logger.Info("Robot Physical configuration loaded successfully");
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Error($"Error loading Robot Physical configuration, using defaults: {ex.Message}");
|
||||
_robotPhysicalConfig = new RobotPhysicalConfig
|
||||
{
|
||||
WheelRadius = 0.1,
|
||||
WheelBase = 0.6,
|
||||
Width = 0.606,
|
||||
Length = 1.106,
|
||||
Height = 0.5,
|
||||
NavigationType = NavigationType.Differential
|
||||
};
|
||||
}
|
||||
finally
|
||||
{
|
||||
_robotPhysicalConfigLoaded = true;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task LoadSimulationConfigAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
var configFile = await _configManager.GetConfigByTypeAsync(SIMULATION_CONFIG_TYPE);
|
||||
|
||||
if (configFile == null)
|
||||
{
|
||||
_logger.Warning("Simulation configuration not found, using defaults");
|
||||
_simulationConfig = new SimulationConfig
|
||||
{
|
||||
IsEnable = false,
|
||||
MaxVelocity = 1.5,
|
||||
MaxAngularVelocity = 0.5,
|
||||
Acceleration = 2.0,
|
||||
Deceleration = 10.0
|
||||
};
|
||||
}
|
||||
else
|
||||
{
|
||||
_simulationConfig = MapConfigVariablesToObject<SimulationConfig>(configFile.Variables);
|
||||
_logger.Info("Simulation configuration loaded successfully");
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Error($"Error loading Simulation configuration, using defaults: {ex.Message}");
|
||||
_simulationConfig = new SimulationConfig
|
||||
{
|
||||
IsEnable = false,
|
||||
MaxVelocity = 1.5,
|
||||
MaxAngularVelocity = 0.5,
|
||||
Acceleration = 2.0,
|
||||
Deceleration = 10.0
|
||||
};
|
||||
}
|
||||
finally
|
||||
{
|
||||
_simulationConfigLoaded = true;
|
||||
}
|
||||
}
|
||||
|
||||
private void OnConfigChanged(object? sender, ConfigChangedEventArgs e)
|
||||
{
|
||||
// Reload config if it's one of our config types
|
||||
if (e.ConfigType == ROBOT_PHYSICAL_CONFIG_TYPE)
|
||||
{
|
||||
lock (_lockObject)
|
||||
{
|
||||
_robotPhysicalConfigLoaded = false;
|
||||
_robotPhysicalConfig = null;
|
||||
}
|
||||
_logger.Info("Robot Physical configuration changed, will reload on next access");
|
||||
}
|
||||
else if (e.ConfigType == SIMULATION_CONFIG_TYPE)
|
||||
{
|
||||
lock (_lockObject)
|
||||
{
|
||||
_simulationConfigLoaded = false;
|
||||
_simulationConfig = null;
|
||||
}
|
||||
_logger.Info("Simulation configuration changed, will reload on next access");
|
||||
}
|
||||
}
|
||||
|
||||
private RobotPhysicalConfig MapConfigVariablesToRobotPhysicalConfig(List<ConfigVariable> variables)
|
||||
{
|
||||
var config = new RobotPhysicalConfig();
|
||||
|
||||
// Get properties with reflection to handle private setters
|
||||
// Use BindingFlags to get properties with private setters
|
||||
var properties = typeof(RobotPhysicalConfig).GetProperties(BindingFlags.Public | BindingFlags.Instance);
|
||||
|
||||
foreach (var property in properties)
|
||||
{
|
||||
// Find variable by exact name match (case-sensitive)
|
||||
var variable = variables.FirstOrDefault(v => v.Name == property.Name);
|
||||
|
||||
if (variable != null && variable.Value != null)
|
||||
{
|
||||
try
|
||||
{
|
||||
var convertedValue = ConvertValue(variable.Value, property.PropertyType, variable.Type);
|
||||
property.SetValue(config, convertedValue);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Warning($"Failed to set property {property.Name} from variable {variable.Name}: {ex.Message}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return config;
|
||||
}
|
||||
|
||||
private T MapConfigVariablesToObject<T>(List<ConfigVariable> variables) where T : new()
|
||||
{
|
||||
var obj = new T();
|
||||
var properties = typeof(T).GetProperties(BindingFlags.Public | BindingFlags.Instance | BindingFlags.SetProperty);
|
||||
|
||||
foreach (var property in properties)
|
||||
{
|
||||
// Find variable by exact name match (case-sensitive)
|
||||
var variable = variables.FirstOrDefault(v => v.Name == property.Name);
|
||||
|
||||
if (variable != null && variable.Value != null)
|
||||
{
|
||||
try
|
||||
{
|
||||
var convertedValue = ConvertValue(variable.Value, property.PropertyType, variable.Type);
|
||||
property.SetValue(obj, convertedValue);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Warning($"Failed to set property {property.Name} from variable {variable.Name}: {ex.Message}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return obj;
|
||||
}
|
||||
|
||||
private object? ConvertValue(object? value, Type targetType, ConfigVariableType variableType)
|
||||
{
|
||||
if (value == null)
|
||||
return targetType.IsValueType ? Activator.CreateInstance(targetType) : null;
|
||||
|
||||
// If value is already of the correct type, return it
|
||||
if (targetType.IsInstanceOfType(value))
|
||||
return value;
|
||||
|
||||
try
|
||||
{
|
||||
// Handle nullable types
|
||||
var underlyingType = Nullable.GetUnderlyingType(targetType) ?? targetType;
|
||||
|
||||
// Convert based on target type
|
||||
if (underlyingType == typeof(string))
|
||||
{
|
||||
return value.ToString() ?? string.Empty;
|
||||
}
|
||||
else if (underlyingType == typeof(int))
|
||||
{
|
||||
if (value is int i) return i;
|
||||
if (int.TryParse(value.ToString(), out var parsed)) return parsed;
|
||||
if (value is double d) return (int)d;
|
||||
throw new InvalidCastException($"Cannot convert {value.GetType()} to int");
|
||||
}
|
||||
else if (underlyingType == typeof(double))
|
||||
{
|
||||
if (value is double d) return d;
|
||||
if (double.TryParse(value.ToString(), out var parsed)) return parsed;
|
||||
if (value is int i) return i;
|
||||
throw new InvalidCastException($"Cannot convert {value.GetType()} to double");
|
||||
}
|
||||
else if (underlyingType == typeof(bool))
|
||||
{
|
||||
if (value is bool b) return b;
|
||||
if (bool.TryParse(value.ToString(), out var parsed)) return parsed;
|
||||
// Handle numeric values: 0/1, "0"/"1", etc.
|
||||
if (value.ToString()?.Trim() == "0" || value.ToString()?.Trim().ToLower() == "false") return false;
|
||||
if (value.ToString()?.Trim() == "1" || value.ToString()?.Trim().ToLower() == "true") return true;
|
||||
throw new InvalidCastException($"Cannot convert {value.GetType()} to bool");
|
||||
}
|
||||
else if (underlyingType.IsEnum)
|
||||
{
|
||||
var enumValue = Enum.Parse(underlyingType, value.ToString() ?? "", true);
|
||||
return enumValue;
|
||||
}
|
||||
// Handle Dictionary types (e.g., Dictionary<SafetySpeed, double>)
|
||||
else if (targetType.IsGenericType && targetType.GetGenericTypeDefinition() == typeof(Dictionary<,>))
|
||||
{
|
||||
return ConvertToDictionary(value, targetType);
|
||||
}
|
||||
else
|
||||
{
|
||||
// Try standard conversion
|
||||
return Convert.ChangeType(value, underlyingType);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Warning($"Error converting value {value} of type {value.GetType()} to {targetType}: {ex.Message}");
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Convert value to Dictionary<TKey, TValue>
|
||||
/// Handles conversion from Dictionary<string, object> (JSON) to typed dictionaries
|
||||
/// Supports enum keys (e.g., Dictionary<SafetySpeed, double>)
|
||||
/// </summary>
|
||||
private object ConvertToDictionary(object value, Type targetDictionaryType)
|
||||
{
|
||||
// Get key and value types from Dictionary<TKey, TValue>
|
||||
var genericArgs = targetDictionaryType.GetGenericArguments();
|
||||
var keyType = genericArgs[0];
|
||||
var valueType = genericArgs[1];
|
||||
|
||||
// Create dictionary instance
|
||||
var dictionaryType = typeof(Dictionary<,>).MakeGenericType(keyType, valueType);
|
||||
var dictionary = Activator.CreateInstance(dictionaryType);
|
||||
var addMethod = dictionaryType.GetMethod("Add") ?? throw new InvalidOperationException("Cannot find Add method on Dictionary");
|
||||
|
||||
// Handle source Dictionary<string, object> from JSON
|
||||
if (value is Dictionary<string, object> sourceDict)
|
||||
{
|
||||
foreach (var kvp in sourceDict)
|
||||
{
|
||||
// Convert key (usually from string to enum)
|
||||
object convertedKey;
|
||||
if (keyType.IsEnum)
|
||||
{
|
||||
// Parse enum from string
|
||||
convertedKey = Enum.Parse(keyType, kvp.Key, ignoreCase: true);
|
||||
}
|
||||
else if (keyType == typeof(string))
|
||||
{
|
||||
convertedKey = kvp.Key;
|
||||
}
|
||||
else
|
||||
{
|
||||
convertedKey = Convert.ChangeType(kvp.Key, keyType);
|
||||
}
|
||||
|
||||
// Convert value
|
||||
object? convertedValue = ConvertDictionaryValue(kvp.Value, valueType);
|
||||
|
||||
// Add to dictionary
|
||||
addMethod.Invoke(dictionary, [convertedKey, convertedValue]);
|
||||
}
|
||||
|
||||
return dictionary ?? new Dictionary<object, object>();
|
||||
}
|
||||
// Handle JsonElement (alternative JSON representation)
|
||||
else if (value is System.Text.Json.JsonElement jsonElement && jsonElement.ValueKind == System.Text.Json.JsonValueKind.Object)
|
||||
{
|
||||
foreach (var property in jsonElement.EnumerateObject())
|
||||
{
|
||||
// Convert key
|
||||
object convertedKey;
|
||||
if (keyType.IsEnum)
|
||||
{
|
||||
convertedKey = Enum.Parse(keyType, property.Name, ignoreCase: true);
|
||||
}
|
||||
else if (keyType == typeof(string))
|
||||
{
|
||||
convertedKey = property.Name;
|
||||
}
|
||||
else
|
||||
{
|
||||
convertedKey = Convert.ChangeType(property.Name, keyType);
|
||||
}
|
||||
|
||||
// Convert value from JsonElement
|
||||
object? convertedValue = ConvertJsonElementValue(property.Value, valueType);
|
||||
|
||||
// Add to dictionary
|
||||
addMethod.Invoke(dictionary, [convertedKey, convertedValue]);
|
||||
}
|
||||
|
||||
return dictionary ?? new Dictionary<object, object>();
|
||||
}
|
||||
else
|
||||
{
|
||||
throw new InvalidCastException($"Cannot convert {value.GetType()} to {targetDictionaryType}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Convert dictionary value to target type
|
||||
/// </summary>
|
||||
private object? ConvertDictionaryValue(object? value, Type valueType)
|
||||
{
|
||||
if (value == null)
|
||||
return valueType.IsValueType ? Activator.CreateInstance(valueType) : null;
|
||||
|
||||
if (valueType == typeof(double))
|
||||
{
|
||||
if (value is double d) return d;
|
||||
if (double.TryParse(value.ToString(), out var parsed)) return parsed;
|
||||
if (value is int i) return (double)i;
|
||||
return Convert.ChangeType(value, valueType);
|
||||
}
|
||||
else if (valueType == typeof(int))
|
||||
{
|
||||
if (value is int i) return i;
|
||||
if (int.TryParse(value.ToString(), out var parsed)) return parsed;
|
||||
if (value is double d) return (int)d;
|
||||
return Convert.ChangeType(value, valueType);
|
||||
}
|
||||
else if (valueType == typeof(string))
|
||||
{
|
||||
return value.ToString() ?? string.Empty;
|
||||
}
|
||||
else if (valueType == typeof(bool))
|
||||
{
|
||||
if (value is bool b) return b;
|
||||
if (bool.TryParse(value.ToString(), out var parsed)) return parsed;
|
||||
return Convert.ChangeType(value, valueType);
|
||||
}
|
||||
else if (valueType.IsEnum)
|
||||
{
|
||||
return Enum.Parse(valueType, value.ToString() ?? "", ignoreCase: true);
|
||||
}
|
||||
else
|
||||
{
|
||||
return Convert.ChangeType(value, valueType);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Convert JsonElement value to target type
|
||||
/// </summary>
|
||||
private object? ConvertJsonElementValue(System.Text.Json.JsonElement element, Type valueType)
|
||||
{
|
||||
if (element.ValueKind == System.Text.Json.JsonValueKind.Null)
|
||||
return valueType.IsValueType ? Activator.CreateInstance(valueType) : null;
|
||||
|
||||
if (valueType == typeof(double))
|
||||
{
|
||||
return element.GetDouble();
|
||||
}
|
||||
else if (valueType == typeof(int))
|
||||
{
|
||||
return element.GetInt32();
|
||||
}
|
||||
else if (valueType == typeof(string))
|
||||
{
|
||||
return element.GetString();
|
||||
}
|
||||
else if (valueType == typeof(bool))
|
||||
{
|
||||
return element.GetBoolean();
|
||||
}
|
||||
else if (valueType.IsEnum)
|
||||
{
|
||||
return Enum.Parse(valueType, element.GetString() ?? "", ignoreCase: true);
|
||||
}
|
||||
else
|
||||
{
|
||||
var rawValue = element.GetRawText();
|
||||
return System.Text.Json.JsonSerializer.Deserialize(rawValue, valueType);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
using RobotNet.VDA5050.State;
|
||||
using RobotNet10.RobotApp.Services.Robot;
|
||||
|
||||
namespace RobotNet10.RobotApp.Services.Exceptions;
|
||||
|
||||
public class ActionException : RobotException
|
||||
{
|
||||
public ActionException(string message) : base(message) { }
|
||||
public ActionException(string message, Exception inner) : base(message, inner) { }
|
||||
public ActionException() : base() { }
|
||||
public ActionException(RobotError error) : base()
|
||||
{
|
||||
Error = error;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
using RobotNet.VDA5050.State;
|
||||
using RobotNet10.RobotApp.Services.Robot;
|
||||
|
||||
namespace RobotNet10.RobotApp.Services.Exceptions;
|
||||
|
||||
public class ModbusException : RobotException
|
||||
{
|
||||
public ModbusException(string message) : base(message) { }
|
||||
public ModbusException(string message, Exception inner) : base(message, inner) { }
|
||||
public ModbusException() : base() { }
|
||||
public ModbusException(RobotError error) : base()
|
||||
{
|
||||
Error = error;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
using RobotNet10.RobotApp.Services.Robot;
|
||||
|
||||
namespace RobotNet10.RobotApp.Services.Exceptions;
|
||||
|
||||
public class NavigationException : RobotException
|
||||
{
|
||||
public NavigationException(string message) : base(message) { }
|
||||
public NavigationException(string message, Exception inner) : base(message, inner) { }
|
||||
public NavigationException() : base() { }
|
||||
public NavigationException(RobotError error) : base()
|
||||
{
|
||||
Error = error;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
using RobotNet.VDA5050.State;
|
||||
using RobotNet10.RobotApp.Services.Robot;
|
||||
|
||||
namespace RobotNet10.RobotApp.Services.Exceptions;
|
||||
|
||||
public class OrderException : RobotException
|
||||
{
|
||||
public OrderException(string message) : base(message) { }
|
||||
public OrderException(string message, Exception inner) : base(message, inner) { }
|
||||
public OrderException() : base() { }
|
||||
public OrderException(RobotError error) : base()
|
||||
{
|
||||
Error = error;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
using RobotNet.VDA5050.State;
|
||||
using RobotNet10.RobotApp.Services.Robot;
|
||||
|
||||
namespace RobotNet10.RobotApp.Services.Exceptions;
|
||||
|
||||
public class PathPlannerException : RobotException
|
||||
{
|
||||
public PathPlannerException(string message) : base(message) { }
|
||||
public PathPlannerException(string message, Exception inner) : base(message, inner) { }
|
||||
public PathPlannerException() : base() { }
|
||||
public PathPlannerException(RobotError error) : base()
|
||||
{
|
||||
Error = error;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
using RobotNet.VDA5050.State;
|
||||
using RobotNet10.RobotApp.Services.Robot;
|
||||
|
||||
namespace RobotNet10.RobotApp.Services.Exceptions;
|
||||
|
||||
public class RobotException : Exception
|
||||
{
|
||||
public RobotException(string message) : base(message) { }
|
||||
public RobotException(string message, Exception inner) : base(message, inner) { }
|
||||
public RobotException() : base() { }
|
||||
public RobotException(RobotError error) : base()
|
||||
{
|
||||
Error = error;
|
||||
}
|
||||
public RobotError? Error { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
using RobotNet.VDA5050.State;
|
||||
using RobotNet10.RobotApp.Services.Robot;
|
||||
|
||||
namespace RobotNet10.RobotApp.Services.Exceptions;
|
||||
|
||||
public class SimulationException : RobotException
|
||||
{
|
||||
public SimulationException(string message) : base(message) { }
|
||||
public SimulationException(string message, Exception inner) : base(message, inner) { }
|
||||
public SimulationException() : base() { }
|
||||
public SimulationException(RobotError error) : base()
|
||||
{
|
||||
Error = error;
|
||||
}
|
||||
}
|
||||
108
srcs/RobotNet10/RobotApp/RobotNet10.RobotApp/Services/Logger.cs
Normal file
108
srcs/RobotNet10/RobotApp/RobotNet10.RobotApp/Services/Logger.cs
Normal file
@@ -0,0 +1,108 @@
|
||||
namespace RobotNet10.RobotApp.Services;
|
||||
|
||||
public class Logger<T>(ILogger<T> Logger) where T : class
|
||||
{
|
||||
public event Action? LoggerUpdate;
|
||||
public void Write(string message, LogLevel level)
|
||||
{
|
||||
switch (level)
|
||||
{
|
||||
case LogLevel.Trace:
|
||||
Logger.LogTrace("{mes}", message);
|
||||
break;
|
||||
case LogLevel.Debug:
|
||||
Logger.LogDebug("{mes}", message);
|
||||
break;
|
||||
case LogLevel.Information:
|
||||
Logger.LogInformation("{mes}", message);
|
||||
break;
|
||||
case LogLevel.Warning:
|
||||
Logger.LogWarning("{mes}", message);
|
||||
break;
|
||||
case LogLevel.Error:
|
||||
Logger.LogError("{mes}", message);
|
||||
break;
|
||||
case LogLevel.Critical:
|
||||
Logger.LogCritical("{mes}", message);
|
||||
break;
|
||||
}
|
||||
LoggerUpdate?.Invoke();
|
||||
}
|
||||
|
||||
public void Write(string message)
|
||||
{
|
||||
Write(message, LogLevel.Information);
|
||||
}
|
||||
|
||||
public async Task WriteAsync(string message)
|
||||
{
|
||||
var write = Task.Run(() => Write(message));
|
||||
await write.WaitAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
public async Task TraceAsync(string message)
|
||||
{
|
||||
var write = Task.Run(() => Write(message, LogLevel.Trace));
|
||||
await write.WaitAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
public async Task DebugAsync(string message)
|
||||
{
|
||||
var write = Task.Run(() => Write(message, LogLevel.Debug));
|
||||
await write.WaitAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
public async Task InfoAsync(string message)
|
||||
{
|
||||
var write = Task.Run(() => Write(message, LogLevel.Information));
|
||||
await write.WaitAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
public async Task WarningAsync(string message)
|
||||
{
|
||||
var write = Task.Run(() => Write(message, LogLevel.Warning));
|
||||
await write.WaitAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
public async Task ErrorAsync(string message)
|
||||
{
|
||||
var write = Task.Run(() => Write(message, LogLevel.Error));
|
||||
await write.WaitAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
public async Task CriticalAsync(string message)
|
||||
{
|
||||
var write = Task.Run(() => Write(message, LogLevel.Critical));
|
||||
await write.WaitAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
public void Trace(string message)
|
||||
{
|
||||
Write(message, LogLevel.Trace);
|
||||
}
|
||||
|
||||
public void Debug(string message)
|
||||
{
|
||||
Write(message, LogLevel.Debug);
|
||||
}
|
||||
|
||||
public void Info(string message)
|
||||
{
|
||||
Write(message, LogLevel.Information);
|
||||
}
|
||||
|
||||
public void Warning(string message)
|
||||
{
|
||||
Write(message, LogLevel.Warning);
|
||||
}
|
||||
|
||||
public void Error(string message)
|
||||
{
|
||||
Write(message, LogLevel.Error);
|
||||
}
|
||||
|
||||
public void Critical(string message)
|
||||
{
|
||||
Write(message, LogLevel.Critical);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
namespace RobotNet10.RobotApp.Services.Navigation;
|
||||
|
||||
public class CPlusNavigation
|
||||
{
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
using RobotNet10.Shared.Numbers;
|
||||
using SysNum = System.Numerics;
|
||||
|
||||
namespace RobotNet10.RobotApp.Services.Navigation.CSharp;
|
||||
|
||||
/// <summary>
|
||||
/// Circular buffer để lưu lịch sử (fixed size)
|
||||
/// </summary>
|
||||
public class CircularBuffer<T>(int capacity) where T : SysNum.INumber<T>
|
||||
{
|
||||
private readonly T[] _buffer = new T[capacity];
|
||||
private int _head = 0;
|
||||
private int _count = 0;
|
||||
private readonly int _capacity = capacity;
|
||||
|
||||
public int Count => _count;
|
||||
public int Capacity => _capacity;
|
||||
|
||||
public void Add(T item)
|
||||
{
|
||||
_buffer[_head] = item;
|
||||
_head = (_head + 1) % _capacity;
|
||||
|
||||
if (_count < _capacity)
|
||||
_count++;
|
||||
}
|
||||
|
||||
public void Clear()
|
||||
{
|
||||
_head = 0;
|
||||
_count = 0;
|
||||
Array.Clear(_buffer, 0, _capacity);
|
||||
}
|
||||
|
||||
public T[] ToArray()
|
||||
{
|
||||
T[] result = new T[_count];
|
||||
for (int i = 0; i < _count; i++)
|
||||
{
|
||||
int index = (_head - _count + i + _capacity) % _capacity;
|
||||
result[i] = _buffer[index];
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
public double Average()
|
||||
{
|
||||
if (_count == 0) return 0;
|
||||
|
||||
T sum = T.Zero;
|
||||
for (int i = 0; i < _count; i++)
|
||||
{
|
||||
int index = (_head - _count + i + _capacity) % _capacity;
|
||||
sum += _buffer[index];
|
||||
}
|
||||
return double.CreateChecked(sum) / _count;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,625 @@
|
||||
using RobotNet10.Common;
|
||||
using RobotNet10.Common.Models;
|
||||
using RobotNet10.RobotApp.Services.Simulation;
|
||||
using RobotDirection = RobotNet10.RobotApp.Shared.Enums.RobotDirection;
|
||||
|
||||
namespace RobotNet10.RobotApp.Services.Navigation.CSharp;
|
||||
|
||||
public class DockToConfig
|
||||
{
|
||||
#region Core Stanley Parameters
|
||||
|
||||
/// <summary>
|
||||
/// Cross-track error gain (K)
|
||||
/// Default: 2.5
|
||||
///
|
||||
/// Meaning: How aggressively to correct lateral position error
|
||||
/// Formula: δ = ψ + arctan(K × e / (v + Ks))
|
||||
///
|
||||
/// ↑ Increase (3.0-5.0):
|
||||
/// ✓ Faster correction of cross-track error
|
||||
/// ✓ Tighter path following
|
||||
/// ✗ May cause oscillation
|
||||
/// ✗ Less smooth on noisy paths
|
||||
///
|
||||
/// ↓ Decrease (1.5-2.0):
|
||||
/// ✓ Smoother motion
|
||||
/// ✓ Less oscillation
|
||||
/// ✗ Slower error correction
|
||||
/// ✗ Larger cross-track error
|
||||
///
|
||||
/// Tuning Tips:
|
||||
/// - Start: 2.5 for general use
|
||||
/// - High precision: 3.0-4.0
|
||||
/// - Smooth priority: 1.5-2.0
|
||||
/// - Check stability by observing steering oscillation
|
||||
/// </summary>
|
||||
public double K { get; set; } = 2.5;
|
||||
|
||||
/// <summary>
|
||||
/// Softening constant (Ks) - meters/second
|
||||
/// Default: 0.1 m/s
|
||||
///
|
||||
/// Meaning: Added to velocity denominator to prevent division by zero at low speeds
|
||||
/// Formula: δ = ψ + arctan(K × e / (v + Ks))
|
||||
///
|
||||
/// ↑ Increase (0.15-0.2):
|
||||
/// ✓ Less aggressive correction at low speed
|
||||
/// ✓ Smoother motion when starting
|
||||
/// ✗ Slower error correction at low speed
|
||||
///
|
||||
/// ↓ Decrease (0.05-0.08):
|
||||
/// ✓ More responsive at low speed
|
||||
/// ✗ May cause oscillation when slow
|
||||
/// ✗ Risk of instability near zero velocity
|
||||
///
|
||||
/// Tuning Tips:
|
||||
/// - Should be ~10% of typical operating velocity
|
||||
/// - If robot oscillates when slow: increase to 0.15-0.2
|
||||
/// - If too sluggish at startup: decrease to 0.05-0.08
|
||||
/// </summary>
|
||||
public double Ks { get; set; } = 0.1;
|
||||
|
||||
#endregion
|
||||
|
||||
#region Vehicle Parameters
|
||||
|
||||
/// <summary>
|
||||
/// Wheelbase (L) - distance between front and rear axles (meters)
|
||||
/// Default: 0.5m
|
||||
///
|
||||
/// Meaning: Distance from rear axle (robot center) to virtual front axle
|
||||
/// Used to calculate front axle position and convert steering angle to angular velocity
|
||||
///
|
||||
/// IMPORTANT: Must match actual robot geometry
|
||||
///
|
||||
/// Formula: ω = (v × tan(δ)) / L
|
||||
/// </summary>
|
||||
public double WheelBase { get; set; } = 0.6;
|
||||
|
||||
/// <summary>
|
||||
/// Maximum steering angle (radians)
|
||||
/// Default: 0.5 rad (≈28.6°)
|
||||
///
|
||||
/// Meaning: Physical limit of equivalent steering angle
|
||||
///
|
||||
/// ↑ Increase (0.6-0.8 rad ≈ 34-46°):
|
||||
/// ✓ Sharper turns possible
|
||||
/// ✗ May exceed robot's turning capability
|
||||
///
|
||||
/// ↓ Decrease (0.3-0.4 rad ≈ 17-23°):
|
||||
/// ✓ Safer, gentler turns
|
||||
/// ✗ Cannot track sharp curves
|
||||
///
|
||||
/// Tuning Tips:
|
||||
/// - Test robot's max practical turn rate
|
||||
/// - Calculate: δ_max = arctan(L × ω_max / v_typical)
|
||||
/// - Example: L=0.5m, ω_max=2rad/s, v=1m/s → δ_max = 0.785 rad (45°)
|
||||
/// - Conservative: 0.4-0.5 rad
|
||||
/// </summary>
|
||||
public double MaxSteeringAngle { get; set; } = 0.5;
|
||||
|
||||
#endregion
|
||||
|
||||
#region Goal Approach Parameters
|
||||
|
||||
/// <summary>
|
||||
/// Distance to start increasing K gain near goal (meters)
|
||||
/// Default: 1.0m
|
||||
///
|
||||
/// Meaning: When within this distance, K gain increases linearly
|
||||
/// to improve tracking accuracy during final approach
|
||||
///
|
||||
/// ↑ Increase (1.5-2.0m):
|
||||
/// ✓ Earlier tightening, smoother transition
|
||||
/// ✗ May be too aggressive on long approach
|
||||
///
|
||||
/// ↓ Decrease (0.5-0.8m):
|
||||
/// ✓ Only tighten very close to goal
|
||||
/// ✗ Less time to correct errors
|
||||
///
|
||||
/// Tuning Tips:
|
||||
/// - Should be larger than GoalTolerance × 10
|
||||
/// - Typical: 0.8-1.5m
|
||||
/// </summary>
|
||||
public double GoalApproachDistance { get; set; } = 1.0;
|
||||
|
||||
/// <summary>
|
||||
/// K gain multiplier at goal position
|
||||
/// Default: 2.0 (K doubles when at goal)
|
||||
///
|
||||
/// Meaning: At goal, effective K = K × GoalGainMultiplier
|
||||
/// Linearly interpolated from 1.0 at GoalApproachDistance to this value at goal
|
||||
///
|
||||
/// ↑ Increase (2.5-3.0):
|
||||
/// ✓ Much tighter tracking near goal
|
||||
/// ✗ Risk of oscillation
|
||||
///
|
||||
/// ↓ Decrease (1.3-1.5):
|
||||
/// ✓ Gentler increase
|
||||
/// ✗ Less improvement near goal
|
||||
///
|
||||
/// Tuning Tips:
|
||||
/// - Start at 2.0
|
||||
/// - If oscillating near goal: decrease to 1.5
|
||||
/// - If still drifting: increase to 2.5
|
||||
/// </summary>
|
||||
public double GoalGainMultiplier { get; set; } = 2.0;
|
||||
|
||||
public double ReachedRadius { get; set; } = 0.03;
|
||||
#endregion
|
||||
|
||||
#region Angular Velocity Limit
|
||||
|
||||
/// <summary>
|
||||
/// Maximum angular velocity during dock-to approach (rad/s)
|
||||
/// Default: 0.8 rad/s
|
||||
///
|
||||
/// Meaning: Clamps the angular velocity output of Stanley controller
|
||||
/// to prevent excessive rotation during docking.
|
||||
///
|
||||
/// ↑ Increase (1.0-1.5):
|
||||
/// ✓ Faster heading correction
|
||||
/// ✗ May overshoot or oscillate during docking
|
||||
///
|
||||
/// ↓ Decrease (0.3-0.5):
|
||||
/// ✓ Smoother, more precise docking
|
||||
/// ✗ Slower heading correction
|
||||
///
|
||||
/// Tuning Tips:
|
||||
/// - Should be lower than PurePursuit/Stanley MaxAngularVelocity for precise docking
|
||||
/// - Start at 0.8, decrease if robot oscillates during dock
|
||||
/// </summary>
|
||||
public double MaxAngularVelocity { get; set; } = 0.05;
|
||||
|
||||
#endregion
|
||||
|
||||
#region Path Resolution
|
||||
|
||||
/// <summary>
|
||||
/// Waypoint spacing for path sampling (meters)
|
||||
/// Default: 0.05m (5cm)
|
||||
///
|
||||
/// Meaning: Distance between interpolated path points
|
||||
/// Same as PurePursuit.ResolutionSplit for consistency
|
||||
/// </summary>
|
||||
public double ResolutionSplit { get; set; } = 0.05;
|
||||
|
||||
#endregion
|
||||
|
||||
public RobotDirection DockToDirection { get; set; } = RobotDirection.FORWARD;
|
||||
public double DockToLength { get; set; } = 3;
|
||||
|
||||
#region Fine Positioning
|
||||
|
||||
/// <summary>
|
||||
/// Timeout per FinePositioning attempt (milliseconds).
|
||||
/// Default: 6000ms (6s)
|
||||
/// </summary>
|
||||
public int FinePositioningTimeoutMs { get; set; } = 6000;
|
||||
|
||||
/// <summary>
|
||||
/// Maximum retries for FinePositioning before transitioning to Error.
|
||||
/// Default: 3
|
||||
/// </summary>
|
||||
public int FinePositioningMaxRetries { get; set; } = 3;
|
||||
|
||||
#endregion
|
||||
|
||||
#region Docking Overshoot & FinePositioning Tuning
|
||||
|
||||
/// <summary>
|
||||
/// Distance (meters) from goal at which overshoot detection begins during Docking.
|
||||
/// Default: 0.2m
|
||||
///
|
||||
/// Meaning: Similar to MovingOvershootDetectionRadius but for Docking phase.
|
||||
/// Smaller default because docking requires higher precision.
|
||||
///
|
||||
/// ↑ Increase (0.3-0.5):
|
||||
/// ✓ Earlier overshoot detection
|
||||
/// ✗ May false-trigger during normal approach deceleration
|
||||
///
|
||||
/// ↓ Decrease (0.1-0.15):
|
||||
/// ✓ Fewer false triggers
|
||||
/// ✗ Robot may overshoot further before detection
|
||||
///
|
||||
/// Tuning Tips:
|
||||
/// - Should be > ReachedRadius (default 0.03m)
|
||||
/// - If robot frequently enters FinePositioning unnecessarily: increase
|
||||
/// - If robot overshoots too far before FP kicks in: decrease
|
||||
/// </summary>
|
||||
public double DockingOvershootDetectionRadius { get; set; } = 0.2;
|
||||
|
||||
/// <summary>
|
||||
/// Distance (meters) from goal at which PID deceleration begins during Docking.
|
||||
/// Default: 3.0m
|
||||
///
|
||||
/// Meaning: When distance to goal > this value, robot runs at MaxLinearVelocity.
|
||||
/// Below this distance, PID ramps velocity down proportionally.
|
||||
///
|
||||
/// ↑ Increase (4-5):
|
||||
/// ✓ Earlier, smoother deceleration — better for heavy robots
|
||||
/// ✗ Slower docking approach
|
||||
///
|
||||
/// ↓ Decrease (1-2):
|
||||
/// ✓ Faster approach — stays at max speed longer
|
||||
/// ✗ Risk of overshoot on high-inertia robots
|
||||
///
|
||||
/// Tuning Tips:
|
||||
/// - Typically smaller than Moving's DecelerationDistance (docking is slower)
|
||||
/// - Should account for DockToMaxSpeed and robot braking capability
|
||||
/// </summary>
|
||||
public double DecelerationDistance { get; set; } = 3.0;
|
||||
|
||||
/// <summary>
|
||||
/// Heading alignment threshold (degrees) for FinePositioning Phase 1 (Align).
|
||||
/// Default: 0.5°
|
||||
///
|
||||
/// Meaning: Robot must align heading within this tolerance before transitioning
|
||||
/// from Align phase to Advance phase.
|
||||
///
|
||||
/// ↑ Increase (1-2°):
|
||||
/// ✓ Faster alignment — less time spent rotating
|
||||
/// ✗ Robot may drift more during advance due to initial heading error
|
||||
///
|
||||
/// ↓ Decrease (0.1-0.3°):
|
||||
/// ✓ More precise heading before advancing
|
||||
/// ✗ Longer alignment time, may oscillate on noisy heading sensor
|
||||
///
|
||||
/// Tuning Tips:
|
||||
/// - Must be < ReAlignThresholdDegrees
|
||||
/// - If robot drifts during advance: decrease
|
||||
/// - If alignment takes too long or oscillates: increase
|
||||
/// </summary>
|
||||
public double FineAlignThresholdDegrees { get; set; } = 0.5;
|
||||
|
||||
/// <summary>
|
||||
/// Heading drift threshold (degrees) to trigger re-alignment during FinePositioning Phase 2 (Advance).
|
||||
/// Default: 3.0°
|
||||
///
|
||||
/// Meaning: If heading error exceeds this during advance, robot stops and re-enters Align phase.
|
||||
///
|
||||
/// ↑ Increase (5-10°):
|
||||
/// ✓ Fewer re-alignment interruptions
|
||||
/// ✗ Robot may approach goal at a large angle, reducing accuracy
|
||||
///
|
||||
/// ↓ Decrease (1-2°):
|
||||
/// ✓ Tighter heading control during advance
|
||||
/// ✗ Frequent re-alignment, slower convergence
|
||||
///
|
||||
/// Tuning Tips:
|
||||
/// - Must be > FineAlignThresholdDegrees (to avoid immediate re-trigger)
|
||||
/// - Typical ratio: ReAlign ≈ 5-10x FineAlign
|
||||
/// - If robot keeps re-aligning: increase or tune AdvanceHeadingCorrectionGain
|
||||
/// </summary>
|
||||
public double ReAlignThresholdDegrees { get; set; } = 3.0;
|
||||
|
||||
/// <summary>
|
||||
/// P-gain for heading correction during FinePositioning Phase 2 (Advance).
|
||||
/// Default: 1.5
|
||||
///
|
||||
/// Meaning: Angular velocity correction = headingError × Gain × effectiveLinearVel
|
||||
/// Higher gain → stronger angular correction while advancing.
|
||||
///
|
||||
/// ↑ Increase (2.0-3.0):
|
||||
/// ✓ Faster heading correction during advance
|
||||
/// ✗ May cause oscillation or jerky steering
|
||||
///
|
||||
/// ↓ Decrease (0.5-1.0):
|
||||
/// ✓ Smoother advance motion
|
||||
/// ✗ Heading drift may accumulate, triggering re-alignment
|
||||
///
|
||||
/// Tuning Tips:
|
||||
/// - Works together with DockToAdvanceMaxAngularVelocity
|
||||
/// - If robot oscillates during advance: decrease
|
||||
/// - If robot drifts and re-aligns too often: increase
|
||||
/// </summary>
|
||||
public double AdvanceHeadingCorrectionGain { get; set; } = 1.5;
|
||||
|
||||
/// <summary>
|
||||
/// Maximum angular velocity (rad/s) for heading correction during FinePositioning Phase 2 (Advance).
|
||||
/// Default: 0.15 rad/s
|
||||
///
|
||||
/// Meaning: Angular correction during advance is clamped to ±this value.
|
||||
/// Independent of NavigationConfig.MaxAngularVelocity (which is tuned for Moving phase).
|
||||
///
|
||||
/// ↑ Increase (0.2-0.3):
|
||||
/// ✓ Stronger heading correction during advance
|
||||
/// ✗ May cause path deviation or jerky steering
|
||||
///
|
||||
/// ↓ Decrease (0.05-0.1):
|
||||
/// ✓ Very smooth, nearly straight-line advance
|
||||
/// ✗ Cannot correct heading drift effectively, may trigger re-alignment
|
||||
///
|
||||
/// Tuning Tips:
|
||||
/// - Should be small relative to DockToRetrySpeed to keep motion smooth
|
||||
/// - If advance path is too curved: decrease
|
||||
/// - If heading correction is too weak and triggers frequent re-align: increase
|
||||
/// </summary>
|
||||
public double DockToAdvanceMaxAngularVelocity { get; set; } = 0.15;
|
||||
|
||||
/// <summary>
|
||||
/// Number of consecutive distance-increasing cycles to trigger overshoot during FinePositioning Advance.
|
||||
/// Default: 3
|
||||
///
|
||||
/// Meaning: During advance, if distance to goal increases for this many consecutive cycles,
|
||||
/// overshoot is declared and a retry is initiated.
|
||||
///
|
||||
/// ↑ Increase (5-7):
|
||||
/// ✓ More tolerant of temporary distance fluctuations (sensor noise)
|
||||
/// ✗ Slower overshoot detection — robot travels further past goal
|
||||
///
|
||||
/// ↓ Decrease (1-2):
|
||||
/// ✓ Faster overshoot detection
|
||||
/// ✗ May false-trigger on sensor noise or minor jitter
|
||||
///
|
||||
/// Tuning Tips:
|
||||
/// - At 30ms cycle: 3 counts = 90ms detection delay
|
||||
/// - Noisy localization: increase to 5-7
|
||||
/// - Stable localization: 2-3 is sufficient
|
||||
/// </summary>
|
||||
public int FinePositioningOvershootCount { get; set; } = 3;
|
||||
|
||||
#endregion
|
||||
|
||||
#region Continuous Goal Update
|
||||
|
||||
/// <summary>
|
||||
/// Maximum allowed position shift (meters) between consecutive goal updates.
|
||||
/// If new goal is farther than this from old goal, it is rejected.
|
||||
/// Default: 0.5m
|
||||
/// </summary>
|
||||
public double MaxGoalPositionShift { get; set; } = 0.2;
|
||||
|
||||
/// <summary>
|
||||
/// Maximum allowed angle shift (degrees) between consecutive goal updates.
|
||||
/// If new goal orientation differs by more than this from old goal, it is rejected.
|
||||
/// Default: 15 degrees
|
||||
/// </summary>
|
||||
public double MaxGoalAngleShiftDegrees { get; set; } = 5.0;
|
||||
|
||||
#endregion
|
||||
|
||||
public DockToConfig Clone() => (DockToConfig)MemberwiseClone();
|
||||
}
|
||||
|
||||
public class DockToController(DockToConfig dockConfig)
|
||||
{
|
||||
public DockToConfig DockConfig { get; private set; } = dockConfig;
|
||||
public List<NavigationNode> Waypoints_Value = [];
|
||||
public NavigationNode Goal = null!;
|
||||
private int _currentWaypointAheadIndex = 0;
|
||||
public NavigationNode StartNode = null!;
|
||||
|
||||
public DockToController WithPath(NavigationNode startNode, NavigationNode currentGoal)
|
||||
{
|
||||
_currentWaypointAheadIndex = 0;
|
||||
StartNode = startNode;
|
||||
Goal = currentGoal;
|
||||
Waypoints_Value = [..PathSplit(StartNode, Goal)];
|
||||
return this;
|
||||
}
|
||||
|
||||
public void ResetTracking()
|
||||
{
|
||||
_currentWaypointAheadIndex = 0;
|
||||
}
|
||||
|
||||
public void UpdateGoal(NavigationNode currentGoal)
|
||||
{
|
||||
Goal = currentGoal;
|
||||
Waypoints_Value = [.. PathSplit(StartNode, Goal)];
|
||||
}
|
||||
|
||||
private NavigationNode[] PathSplit(NavigationNode startNode, NavigationNode currentGoal)
|
||||
{
|
||||
List<NavigationNode> navigationNode = [startNode];
|
||||
var spaceEdge = new SpaceEdge()
|
||||
{
|
||||
StartX = startNode.X,
|
||||
StartY = startNode.Y,
|
||||
EndX = currentGoal.X,
|
||||
EndY = currentGoal.Y,
|
||||
Degree = 1,
|
||||
};
|
||||
|
||||
double length = SpaceCompute.GetEdgeLength(spaceEdge, DockConfig.ResolutionSplit);
|
||||
if (length <= 0) return [];
|
||||
double step = DockConfig.ResolutionSplit / length;
|
||||
|
||||
for (double t = step; t <= 1 - step; t += step)
|
||||
{
|
||||
(double x, double y) = SpaceCompute.BezierPoint(t, spaceEdge);
|
||||
navigationNode.Add(new()
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
NodeId = string.Empty,
|
||||
X = x,
|
||||
Y = y,
|
||||
Theta = null,
|
||||
Direction = DockConfig.DockToDirection,
|
||||
Speed = startNode.Speed,
|
||||
});
|
||||
}
|
||||
navigationNode.Add(currentGoal);
|
||||
return [.. navigationNode];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Calculate distance between two points
|
||||
/// </summary>
|
||||
private static double CalculateDistance(double x1, double y1, double x2, double y2)
|
||||
{
|
||||
double dx = x2 - x1;
|
||||
double dy = y2 - y1;
|
||||
return Math.Sqrt(dx * dx + dy * dy);
|
||||
}
|
||||
|
||||
public (NavigationNode node, int index) GetClosestAheadWaypoint(double x, double y)
|
||||
{
|
||||
if (Waypoints_Value.Count == 0)
|
||||
throw new InvalidOperationException("Path not set");
|
||||
|
||||
double minDistance = double.MaxValue;
|
||||
int closestIndex = 0;
|
||||
|
||||
// Start search from current index for efficiency
|
||||
for (int i = _currentWaypointAheadIndex; i < Waypoints_Value.Count; i++)
|
||||
{
|
||||
double distance = CalculateDistance(x, y, Waypoints_Value[i].X, Waypoints_Value[i].Y);
|
||||
|
||||
if (distance < minDistance)
|
||||
{
|
||||
minDistance = distance;
|
||||
closestIndex = i;
|
||||
}
|
||||
}
|
||||
|
||||
// Also check previous waypoints in case robot moved backwards
|
||||
for (int i = 0; i < _currentWaypointAheadIndex; i++)
|
||||
{
|
||||
double distance = CalculateDistance(x, y, Waypoints_Value[i].X, Waypoints_Value[i].Y);
|
||||
|
||||
if (distance < minDistance)
|
||||
{
|
||||
minDistance = distance;
|
||||
closestIndex = i;
|
||||
}
|
||||
}
|
||||
|
||||
_currentWaypointAheadIndex = closestIndex;
|
||||
return (Waypoints_Value[closestIndex], closestIndex);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Calculate path heading at given waypoint index (for Stanley)
|
||||
/// Uses current point and next point to determine direction
|
||||
/// </summary>
|
||||
private double CalculatePathHeading(int index)
|
||||
{
|
||||
if (index >= Waypoints_Value.Count - 1)
|
||||
{
|
||||
// Last point - use previous segment direction
|
||||
if (index > 0)
|
||||
{
|
||||
double dx = Waypoints_Value[index].X - Waypoints_Value[index - 1].X;
|
||||
double dy = Waypoints_Value[index].Y - Waypoints_Value[index - 1].Y;
|
||||
return Math.Atan2(dy, dx);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Use current to next point
|
||||
double dxNext = Waypoints_Value[index + 1].X - Waypoints_Value[index].X;
|
||||
double dyNext = Waypoints_Value[index + 1].Y - Waypoints_Value[index].Y;
|
||||
return Math.Atan2(dyNext, dxNext);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Calculate signed cross-track error (for Stanley)
|
||||
/// Positive: front axle is to the left of path
|
||||
/// Negative: front axle is to the right of path
|
||||
/// </summary>
|
||||
private static double CalculateStanleyCrossTrackError(double frontX, double frontY, NavigationNode closestPoint, double pathHeading)
|
||||
{
|
||||
// Vector from closest point to front axle
|
||||
double dx = frontX - closestPoint.X;
|
||||
double dy = frontY - closestPoint.Y;
|
||||
|
||||
// Path direction vector
|
||||
double pathDx = Math.Cos(pathHeading);
|
||||
double pathDy = Math.Sin(pathHeading);
|
||||
|
||||
// Cross product to get signed perpendicular distance
|
||||
// positive = left, negative = right
|
||||
double crossTrackError = dx * pathDy - dy * pathDx;
|
||||
|
||||
return crossTrackError;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Normalize angle to [-π, π]
|
||||
/// </summary>
|
||||
private static double NormalizeAngle(double angle)
|
||||
{
|
||||
while (angle > Math.PI) angle -= 2 * Math.PI;
|
||||
while (angle < -Math.PI) angle += 2 * Math.PI;
|
||||
return angle;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Stanley-based final approach controller
|
||||
/// When robot enters goal region (IsApproachGoal), uses Stanley algorithm for precise CTE-based tracking
|
||||
/// </summary>
|
||||
public (double linearVel, double angularVel) FinalApproachController(
|
||||
double robotX,
|
||||
double robotY,
|
||||
double robotTheta,
|
||||
double actualLinearVelocity,
|
||||
double maxLinearVelocity)
|
||||
{
|
||||
if (Goal is null || Waypoints_Value.Count == 0) return (0, 0);
|
||||
|
||||
if (DockConfig.DockToDirection == RobotDirection.BACKWARD) robotTheta += Math.PI;
|
||||
robotTheta = NormalizeAngle(robotTheta);
|
||||
|
||||
// Calculate front axle position
|
||||
double frontX = robotX + DockConfig.WheelBase * Math.Cos(robotTheta);
|
||||
double frontY = robotY + DockConfig.WheelBase * Math.Sin(robotTheta);
|
||||
|
||||
// Find closest point on path to front axle
|
||||
var (closestPoint, closestIndex) = GetClosestAheadWaypoint(frontX, frontY);
|
||||
|
||||
// Calculate heading at closest point (path direction)
|
||||
double pathHeading = CalculatePathHeading(closestIndex);
|
||||
|
||||
// Calculate cross-track error (signed distance from front axle to path)
|
||||
double crossTrackError = CalculateStanleyCrossTrackError(frontX, frontY, closestPoint, pathHeading);
|
||||
if (DockConfig.DockToDirection == RobotDirection.BACKWARD) crossTrackError = -crossTrackError;
|
||||
|
||||
// Calculate heading error (path heading - robot heading)
|
||||
double headingError = NormalizeAngle(pathHeading - robotTheta);
|
||||
|
||||
// Adaptive K gain: increase when close to goal for tighter tracking
|
||||
double distanceToGoal = CalculateDistance(robotX, robotY, Goal.X, Goal.Y);
|
||||
double adaptiveK = DockConfig.K;
|
||||
if (distanceToGoal < DockConfig.GoalApproachDistance)
|
||||
{
|
||||
// Linearly increase K from K to K*GoalGainMultiplier as distance decreases
|
||||
double approachRatio = 1.0 - (distanceToGoal / DockConfig.GoalApproachDistance);
|
||||
adaptiveK = DockConfig.K * (1.0 + approachRatio * (DockConfig.GoalGainMultiplier - 1.0));
|
||||
}
|
||||
|
||||
// Stanley formula: δ = ψ + arctan(K × e / (v + Ks))
|
||||
double crossTrackTerm = Math.Atan2(adaptiveK * crossTrackError, Math.Abs(actualLinearVelocity) + DockConfig.Ks);
|
||||
|
||||
// Total steering angle
|
||||
double steeringAngle = headingError + crossTrackTerm;
|
||||
|
||||
// Clamp to maximum steering angle
|
||||
steeringAngle = Math.Clamp(steeringAngle, -DockConfig.MaxSteeringAngle, DockConfig.MaxSteeringAngle);
|
||||
|
||||
// Convert steering angle to angular velocity using bicycle model
|
||||
double angularVelocity = (actualLinearVelocity * Math.Tan(steeringAngle)) / DockConfig.WheelBase;
|
||||
|
||||
// Clamp angular velocity for dock-to approach
|
||||
angularVelocity = Math.Clamp(angularVelocity, -DockConfig.MaxAngularVelocity, DockConfig.MaxAngularVelocity);
|
||||
|
||||
// Apply direction to velocities
|
||||
double linearVel = maxLinearVelocity;
|
||||
if (DockConfig.DockToDirection == RobotDirection.BACKWARD)
|
||||
{
|
||||
linearVel = -linearVel;
|
||||
}
|
||||
|
||||
// Debug output
|
||||
Console.WriteLine($"DT-Stanley: Front=({frontX:F3},{frontY:F3}), Closest=({closestPoint.X:F3},{closestPoint.Y:F3}), " +
|
||||
$"Pose=({robotX:F3},{robotY:F3},{robotTheta * 180 / Math.PI:F1}°), " +
|
||||
$"CTE={crossTrackError:F3}m, HeadErr={headingError * 180 / Math.PI:F1}°, " +
|
||||
$"SteerAng={steeringAngle * 180 / Math.PI:F1}°, " +
|
||||
$"K={adaptiveK:F2}, DTG={distanceToGoal:F3}m, " +
|
||||
$"LVel={linearVel:F3}, AnVel={angularVelocity:F3}");
|
||||
|
||||
return (linearVel, angularVelocity);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,333 @@
|
||||
namespace RobotNet10.RobotApp.Services.Navigation.CSharp;
|
||||
|
||||
public class FuzzyLogic
|
||||
{
|
||||
private double Gain_P = 0.5;
|
||||
private double Gain_I = 0.01;
|
||||
private double piIntegratorState; // Trạng thái tích phân của PI controller
|
||||
|
||||
// Các tham số cho membership functions hình thang của tín hiệu góc
|
||||
// Negative Large: [-∞, -∞, -1.0, -0.5]
|
||||
private static readonly double[] NegativeLargeAngularParams = [-1.0E+10, -1.0E+10, -1.0, -0.5];
|
||||
// Positive Large: [0.5, 1.0, +∞, +∞]
|
||||
private static readonly double[] PositiveLargeAngularParams = [0.5, 1.0, 1.0E+10, 1.0E+10];
|
||||
|
||||
// Các tham số cho membership functions hình thang của vận tốc
|
||||
// High Velocity: [0.75, 1.0, +∞, +∞]
|
||||
private static readonly double[] HighVelocityParams = [0.75, 1.0, 1.0E+9, 1.0E+9];
|
||||
// Low Velocity: [-∞, -∞, 0.0, 0.25]
|
||||
private static readonly double[] LowVelocityParams = [-1.0E+9, -1.0E+9, 0.0, 0.25];
|
||||
|
||||
// Mảng quy tắc cho bộ điều khiển bánh phải (wr)
|
||||
// 25 phần tử đầu: chỉ số membership function cho tín hiệu góc (input 1)
|
||||
// 25 phần tử sau: chỉ số membership function cho vận tốc (input 2)
|
||||
private static readonly byte[] RightWheelRuleInput1Indices = [ 1, 1, 1, 1, 1, 2, 2, 2, 2, 2, 3, 3, 3, 3, 3, 4,
|
||||
4, 4, 4, 4, 5, 5, 5, 5, 5, 1, 2, 3, 4, 5, 1, 2, 3,
|
||||
4, 5, 1, 2, 3, 4, 5, 3, 4, 5, 1, 2, 1, 2, 3, 4, 5 ];
|
||||
// Mảng quy tắc output cho bánh phải (25 quy tắc)
|
||||
private static readonly byte[] RightWheelRuleOutputIndices = [1, 1, 2, 1, 1, 2, 3, 5, 1, 4, 5, 5, 5, 5, 5, 2, 1, 1, 1, 1, 5, 5, 5, 5, 5];
|
||||
|
||||
// Mảng quy tắc cho bộ điều khiển bánh trái (wl)
|
||||
private static readonly byte[] LeftWheelRuleInput1Indices = [ 1, 1, 1, 1, 1, 2, 2, 2, 2, 2, 3, 3, 3, 3, 3, 4,
|
||||
4, 4, 4, 4, 5, 5, 5, 5, 5, 1, 2, 3, 4, 5, 4, 1,
|
||||
2, 3, 5, 3, 1, 2, 4, 5, 1, 2, 3, 4, 5, 1, 2, 4, 5, 3 ];
|
||||
private static readonly byte[] LeftWheelRuleOutputIndices = [5, 5, 5, 5, 5, 1, 2, 3, 5, 4, 2, 1, 1, 1, 1, 5, 5, 5, 5, 5, 1, 1, 1, 1, 2];
|
||||
|
||||
public void SetGainP(double gainP)
|
||||
{
|
||||
Gain_P = gainP;
|
||||
}
|
||||
|
||||
public void SetGainI(double gainI)
|
||||
{
|
||||
Gain_I = gainI;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tính toán giá trị membership cho hàm hình thang (trapezoidal membership function).
|
||||
/// </summary>
|
||||
/// <param name="inputValue">Giá trị đầu vào cần tính độ thuộc</param>
|
||||
/// <param name="parameters">Mảng 4 phần tử: [a, b, c, d] trong đó:
|
||||
/// - a: điểm bắt đầu của cạnh tăng (left foot)
|
||||
/// - b: điểm bắt đầu của phần phẳng (left shoulder)
|
||||
/// - c: điểm kết thúc của phần phẳng (right shoulder)
|
||||
/// - d: điểm kết thúc của cạnh giảm (right foot)</param>
|
||||
/// <returns>Giá trị membership trong khoảng [0, 1]</returns>
|
||||
private static double Fuzzy_trapmf(double inputValue, double[] parameters)
|
||||
{
|
||||
// Extract các tham số để dễ đọc
|
||||
double leftFoot = parameters[0]; // a: điểm bắt đầu tăng
|
||||
double leftShoulder = parameters[1]; // b: điểm bắt đầu phẳng
|
||||
double rightShoulder = parameters[2]; // c: điểm kết thúc phẳng
|
||||
double rightFoot = parameters[3]; // d: điểm kết thúc giảm
|
||||
|
||||
// Tính giá trị membership từ cạnh trái (từ a đến b)
|
||||
double leftMembership = 0.0;
|
||||
if (inputValue < leftFoot)
|
||||
{
|
||||
// Ngoài vùng hình thang bên trái
|
||||
leftMembership = 0.0;
|
||||
}
|
||||
else if (inputValue >= leftShoulder)
|
||||
{
|
||||
// Trong vùng phẳng bên trái
|
||||
leftMembership = 1.0;
|
||||
}
|
||||
else if (leftFoot != leftShoulder)
|
||||
{
|
||||
// Trên cạnh tăng (tính toán tuyến tính từ a đến b)
|
||||
leftMembership = (inputValue - leftFoot) / (leftShoulder - leftFoot);
|
||||
}
|
||||
|
||||
// Tính giá trị membership từ cạnh phải (từ c đến d)
|
||||
double rightMembership = 0.0;
|
||||
if (inputValue <= rightShoulder)
|
||||
{
|
||||
// Trong vùng phẳng bên phải
|
||||
rightMembership = 1.0;
|
||||
}
|
||||
else if (inputValue > rightFoot)
|
||||
{
|
||||
// Ngoài vùng hình thang bên phải
|
||||
rightMembership = 0.0;
|
||||
}
|
||||
else if (rightShoulder != rightFoot)
|
||||
{
|
||||
// Trên cạnh giảm (tính toán tuyến tính từ c đến d)
|
||||
rightMembership = (rightFoot - inputValue) / (rightFoot - rightShoulder);
|
||||
}
|
||||
|
||||
// Kết quả là giá trị nhỏ hơn để đảm bảo không vượt quá 1.0
|
||||
return leftMembership < rightMembership ? leftMembership : rightMembership;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tính toán giá trị membership cho hàm tam giác (triangular membership function).
|
||||
/// </summary>
|
||||
/// <param name="inputValue">Giá trị đầu vào cần tính độ thuộc</param>
|
||||
/// <param name="parameters">Mảng 3 phần tử: [a, b, c] trong đó:
|
||||
/// - a: điểm bắt đầu (left foot)
|
||||
/// - b: điểm đỉnh (peak) - giá trị membership = 1.0
|
||||
/// - c: điểm kết thúc (right foot)</param>
|
||||
/// <returns>Giá trị membership trong khoảng [0, 1]</returns>
|
||||
private static double Fuzzy_trimf(double inputValue, double[] parameters)
|
||||
{
|
||||
// Extract các tham số để dễ đọc
|
||||
double leftFoot = parameters[0]; // a: điểm bắt đầu
|
||||
double peak = parameters[1]; // b: điểm đỉnh
|
||||
double rightFoot = parameters[2]; // c: điểm kết thúc
|
||||
|
||||
// Kiểm tra nếu giá trị nằm ngoài vùng tam giác
|
||||
if (inputValue < leftFoot || inputValue > rightFoot)
|
||||
{
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
// Nếu giá trị tại đỉnh, membership = 1.0
|
||||
if (inputValue == peak)
|
||||
{
|
||||
return 1.0;
|
||||
}
|
||||
|
||||
// Tính toán membership trên cạnh tăng (từ a đến b)
|
||||
if (leftFoot < inputValue && inputValue < peak && leftFoot != peak)
|
||||
{
|
||||
return (inputValue - leftFoot) / (peak - leftFoot);
|
||||
}
|
||||
|
||||
// Tính toán membership trên cạnh giảm (từ b đến c)
|
||||
if (peak < inputValue && inputValue < rightFoot && peak != rightFoot)
|
||||
{
|
||||
return (rightFoot - inputValue) / (rightFoot - peak);
|
||||
}
|
||||
|
||||
// Trường hợp đặc biệt: nếu không khớp với điều kiện nào
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tính toán vận tốc bánh trái và bánh phải dựa trên fuzzy logic controller.
|
||||
/// </summary>
|
||||
/// <param name="v">Vận tốc tuyến tính (linear velocity) - giá trị chuẩn hóa [0, 1]</param>
|
||||
/// <param name="w">Vận tốc góc (angular velocity)</param>
|
||||
/// <param name="timeSample">Thời gian mẫu (sampling time) cho tích phân - phải > 0</param>
|
||||
/// <returns>Tuple chứa (wl: vận tốc bánh trái, wr: vận tốc bánh phải) - giá trị chuẩn hóa [0, 1]</returns>
|
||||
/// <exception cref="ArgumentException">Thrown khi timeSample <= 0 hoặc các tham số không hợp lệ</exception>
|
||||
public (double wl, double wr) Fuzzy_step(double v, double w, double timeSample)
|
||||
{
|
||||
// Validation đầu vào
|
||||
if (timeSample <= 0.0 || double.IsNaN(timeSample) || double.IsInfinity(timeSample))
|
||||
{
|
||||
throw new ArgumentException("timeSample must be a positive finite number", nameof(timeSample));
|
||||
}
|
||||
if (double.IsNaN(v) || double.IsNaN(w) || double.IsInfinity(v) || double.IsInfinity(w))
|
||||
{
|
||||
throw new ArgumentException("Input parameters v and w must be finite numbers", nameof(v));
|
||||
}
|
||||
|
||||
(double wl, double wr) result = new();
|
||||
|
||||
// Cache cho các giá trị membership của đầu vào (10 membership functions)
|
||||
// [0-4]: membership functions cho tín hiệu góc đã xử lý
|
||||
// [5-9]: membership functions cho vận tốc tuyến tính
|
||||
double[] inputMembershipValues = new double[10];
|
||||
|
||||
// Cache cho các giá trị membership của đầu ra (5 levels: 0.0, 0.25, 0.5, 0.75, 1.0)
|
||||
double[] outputMembershipValuesRight = new double[5]; // Cho bánh phải (wr)
|
||||
double[] outputMembershipValuesLeft = new double[5]; // Cho bánh trái (wl)
|
||||
|
||||
// Mảng tạm để chứa tham số cho hàm tam giác (3 phần tử: [a, b, c])
|
||||
double[] triangularParams = new double[3];
|
||||
|
||||
// Các biến tạm để cache kết quả fuzzification (tránh tính toán lại)
|
||||
double negativeLargeMembership;
|
||||
double positiveLargeMembership;
|
||||
double highVelocityMembership;
|
||||
double lowVelocityMembership;
|
||||
// ========== BƯỚC 1: Xử lý tín hiệu đầu vào bằng PI Controller ==========
|
||||
// Tích phân vận tốc góc để loại bỏ sai số ổn định
|
||||
piIntegratorState += Gain_I * w * timeSample;
|
||||
// Kết hợp thành phần tỷ lệ và tích phân
|
||||
double piControllerOutput = Gain_P * w + piIntegratorState;
|
||||
|
||||
// ========== BƯỚC 2: Fuzzification - Chuyển đổi đầu vào thành độ thuộc ==========
|
||||
// Tính toán membership values cho tín hiệu góc đã xử lý (5 membership functions)
|
||||
|
||||
// MF1: Negative Large (hình thang)
|
||||
negativeLargeMembership = Fuzzy_trapmf(piControllerOutput, NegativeLargeAngularParams);
|
||||
inputMembershipValues[0] = negativeLargeMembership;
|
||||
|
||||
// MF2: Negative (tam giác: -0.5, 0.0, 0.5)
|
||||
triangularParams[0] = -0.5;
|
||||
triangularParams[1] = 0.0;
|
||||
triangularParams[2] = 0.5;
|
||||
inputMembershipValues[1] = Fuzzy_trimf(piControllerOutput, triangularParams);
|
||||
|
||||
// MF3: Positive Large (hình thang)
|
||||
positiveLargeMembership = Fuzzy_trapmf(piControllerOutput, PositiveLargeAngularParams);
|
||||
inputMembershipValues[2] = positiveLargeMembership;
|
||||
|
||||
// MF4: Very Negative (tam giác: -1.0, -0.5, 0.0)
|
||||
triangularParams[0] = -1.0;
|
||||
triangularParams[1] = -0.5;
|
||||
triangularParams[2] = 0.0;
|
||||
inputMembershipValues[3] = Fuzzy_trimf(piControllerOutput, triangularParams);
|
||||
|
||||
// MF5: Positive (tam giác: 0.0, 0.5, 1.0)
|
||||
triangularParams[0] = 0.0;
|
||||
triangularParams[1] = 0.5;
|
||||
triangularParams[2] = 1.0;
|
||||
inputMembershipValues[4] = Fuzzy_trimf(piControllerOutput, triangularParams);
|
||||
|
||||
// Tính toán membership values cho vận tốc tuyến tính (5 membership functions)
|
||||
|
||||
// MF6: Low (tam giác: 0.0, 0.25, 0.5)
|
||||
triangularParams[0] = 0.0;
|
||||
triangularParams[1] = 0.25;
|
||||
triangularParams[2] = 0.5;
|
||||
inputMembershipValues[5] = Fuzzy_trimf(v, triangularParams);
|
||||
|
||||
// MF7: Medium (tam giác: 0.25, 0.5, 0.75)
|
||||
triangularParams[0] = 0.25;
|
||||
triangularParams[1] = 0.5;
|
||||
triangularParams[2] = 0.75;
|
||||
inputMembershipValues[6] = Fuzzy_trimf(v, triangularParams);
|
||||
|
||||
// MF8: High (hình thang)
|
||||
highVelocityMembership = Fuzzy_trapmf(v, HighVelocityParams);
|
||||
inputMembershipValues[7] = highVelocityMembership;
|
||||
|
||||
// MF9: Very Low (hình thang)
|
||||
lowVelocityMembership = Fuzzy_trapmf(v, LowVelocityParams);
|
||||
inputMembershipValues[8] = lowVelocityMembership;
|
||||
|
||||
// MF10: High-Medium (tam giác: 0.5, 0.75, 1.0)
|
||||
triangularParams[0] = 0.5;
|
||||
triangularParams[1] = 0.75;
|
||||
triangularParams[2] = 1.0;
|
||||
inputMembershipValues[9] = Fuzzy_trimf(v, triangularParams);
|
||||
// ========== BƯỚC 3: Tính toán vận tốc bánh phải (wr) ==========
|
||||
// Khởi tạo giá trị membership cho đầu ra (5 mức: 0.0, 0.25, 0.5, 0.75, 1.0)
|
||||
outputMembershipValuesRight[0] = 0.0;
|
||||
outputMembershipValuesRight[1] = 0.25;
|
||||
outputMembershipValuesRight[2] = 0.5;
|
||||
outputMembershipValuesRight[3] = 0.75;
|
||||
outputMembershipValuesRight[4] = 1.0;
|
||||
|
||||
// Đánh giá 25 quy tắc fuzzy và tính toán defuzzification
|
||||
double totalRuleActivation = 0.0;
|
||||
double weightedOutputSum = 0.0;
|
||||
const int numberOfRules = 25;
|
||||
|
||||
for (int ruleIndex = 0; ruleIndex < numberOfRules; ruleIndex++)
|
||||
{
|
||||
// Tính độ kích hoạt của quy tắc: product(input1_membership, input2_membership)
|
||||
// Sử dụng phép nhân (product) thay vì min() cho fuzzy AND operation
|
||||
// input1: tín hiệu góc (index từ RightWheelRuleInput1Indices[ruleIndex] - 1, vì mảng bắt đầu từ 0)
|
||||
// input2: vận tốc (index từ RightWheelRuleInput1Indices[ruleIndex + 25] + 4, offset 4 vì vận tốc bắt đầu từ index 5)
|
||||
int angularSignalIndex = RightWheelRuleInput1Indices[ruleIndex] - 1;
|
||||
int velocityIndex = RightWheelRuleInput1Indices[ruleIndex + numberOfRules] + 4;
|
||||
double ruleActivation = inputMembershipValues[velocityIndex] * inputMembershipValues[angularSignalIndex];
|
||||
|
||||
totalRuleActivation += ruleActivation;
|
||||
|
||||
// Tính tổng có trọng số cho defuzzification (Center of Gravity)
|
||||
int outputIndex = RightWheelRuleOutputIndices[ruleIndex] - 1;
|
||||
weightedOutputSum += outputMembershipValuesRight[outputIndex] * ruleActivation;
|
||||
}
|
||||
|
||||
// Defuzzification: Center of Gravity method
|
||||
if (totalRuleActivation == 0.0)
|
||||
{
|
||||
// Nếu không có quy tắc nào được kích hoạt, trả về giá trị mặc định
|
||||
result.wr = 0.5;
|
||||
}
|
||||
else
|
||||
{
|
||||
result.wr = weightedOutputSum / totalRuleActivation;
|
||||
}
|
||||
// ========== BƯỚC 4: Tính toán vận tốc bánh trái (wl) ==========
|
||||
// Sử dụng lại các giá trị membership đã tính ở BƯỚC 2 (không cần tính lại vì không thay đổi)
|
||||
// Các giá trị trong inputMembershipValues[0-9] đã được tính toán và lưu trữ ở BƯỚC 2
|
||||
|
||||
// Khởi tạo giá trị membership cho đầu ra bánh trái
|
||||
outputMembershipValuesLeft[0] = 0.0;
|
||||
outputMembershipValuesLeft[1] = 0.25;
|
||||
outputMembershipValuesLeft[2] = 0.5;
|
||||
outputMembershipValuesLeft[3] = 0.75;
|
||||
outputMembershipValuesLeft[4] = 1.0;
|
||||
|
||||
// Đánh giá 25 quy tắc fuzzy cho bánh trái và tính toán defuzzification
|
||||
totalRuleActivation = 0.0;
|
||||
weightedOutputSum = 0.0;
|
||||
|
||||
for (int ruleIndex = 0; ruleIndex < numberOfRules; ruleIndex++)
|
||||
{
|
||||
// Tính độ kích hoạt của quy tắc cho bánh trái: product(input1_membership, input2_membership)
|
||||
// Sử dụng phép nhân (product) thay vì min() cho fuzzy AND operation
|
||||
// input1: tín hiệu góc (index từ LeftWheelRuleInput1Indices[ruleIndex] - 1)
|
||||
// input2: vận tốc (index từ LeftWheelRuleInput1Indices[ruleIndex + 25] + 4)
|
||||
int angularSignalIndex = LeftWheelRuleInput1Indices[ruleIndex] - 1;
|
||||
int velocityIndex = LeftWheelRuleInput1Indices[ruleIndex + numberOfRules] + 4;
|
||||
double ruleActivation = inputMembershipValues[velocityIndex] * inputMembershipValues[angularSignalIndex];
|
||||
|
||||
totalRuleActivation += ruleActivation;
|
||||
|
||||
// Tính tổng có trọng số cho defuzzification
|
||||
int outputIndex = LeftWheelRuleOutputIndices[ruleIndex] - 1;
|
||||
weightedOutputSum += outputMembershipValuesLeft[outputIndex] * ruleActivation;
|
||||
}
|
||||
|
||||
// Defuzzification: Center of Gravity method cho bánh trái
|
||||
if (totalRuleActivation == 0.0)
|
||||
{
|
||||
// Nếu không có quy tắc nào được kích hoạt, trả về giá trị mặc định
|
||||
result.wl = 0.5;
|
||||
}
|
||||
else
|
||||
{
|
||||
result.wl = weightedOutputSum / totalRuleActivation;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
namespace RobotNet10.RobotApp.Services.Navigation.CSharp;
|
||||
|
||||
public enum ApproachResult
|
||||
{
|
||||
/// <summary>
|
||||
/// Approach waypoints đã sinh và prepend thành công
|
||||
/// </summary>
|
||||
ApproachGenerated,
|
||||
|
||||
/// <summary>
|
||||
/// Robot đã trên path, không cần approach
|
||||
/// </summary>
|
||||
AlreadyOnPath,
|
||||
|
||||
/// <summary>
|
||||
/// Robot quá xa path, fallback Rotate cũ
|
||||
/// </summary>
|
||||
TooFarFromPath,
|
||||
|
||||
/// <summary>
|
||||
/// LocalPlanner tắt hoặc path quá ngắn
|
||||
/// </summary>
|
||||
Disabled
|
||||
}
|
||||
|
||||
public class LocalPlannerConfig
|
||||
{
|
||||
/// <summary>
|
||||
/// Bật/tắt Local Planner
|
||||
/// </summary>
|
||||
public bool Enabled { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Khoảng cách dưới mức này coi robot đã trên path, không cần approach (m)
|
||||
/// </summary>
|
||||
public double OnPathThreshold { get; set; } = 0.3;
|
||||
|
||||
/// <summary>
|
||||
/// Khoảng cách trên mức này thì fallback Rotate cũ (m)
|
||||
/// </summary>
|
||||
public double MaxApproachDistance { get; set; } = 3.0;
|
||||
|
||||
/// <summary>
|
||||
/// Hệ số tính merge distance: mergeDistance = MergeDistanceGain × distToPath
|
||||
/// </summary>
|
||||
public double MergeDistanceGain { get; set; } = 2.0;
|
||||
|
||||
/// <summary>
|
||||
/// Merge distance tối thiểu (m)
|
||||
/// </summary>
|
||||
public double MergeDistanceMin { get; set; } = 0.3;
|
||||
|
||||
/// <summary>
|
||||
/// Merge distance tối đa (m)
|
||||
/// </summary>
|
||||
public double MergeDistanceMax { get; set; } = 2.0;
|
||||
|
||||
/// <summary>
|
||||
/// Tỷ lệ control arm phía robot (P1): d1 = ratio × dist(P0, P3)
|
||||
/// </summary>
|
||||
public double ControlArmRatioStart { get; set; } = 0.4;
|
||||
|
||||
/// <summary>
|
||||
/// Tỷ lệ control arm phía path (P2): d2 = ratio × dist(P0, P3)
|
||||
/// </summary>
|
||||
public double ControlArmRatioEnd { get; set; } = 0.4;
|
||||
|
||||
/// <summary>
|
||||
/// Bước sample approach curve (m), nên giống PurePursuitConfig.ResolutionSplit
|
||||
/// </summary>
|
||||
public double ResolutionSplit { get; set; } = 0.05;
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
namespace RobotNet10.RobotApp.Services.Navigation.CSharp;
|
||||
|
||||
public class MotorDynamicsConfig
|
||||
{
|
||||
/// <summary>
|
||||
/// Time constant (τ) - thời gian để motor đạt 63.2% của target velocity
|
||||
/// Đơn vị: giây (s)
|
||||
/// Typical: 0.1 - 0.5s cho DC motor với driver PID
|
||||
/// </summary>
|
||||
public double Tau { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Pure delay (δ) - độ trễ trước khi motor bắt đầu phản ứng
|
||||
/// Đơn vị: giây (s)
|
||||
/// Bao gồm: communication delay + driver processing
|
||||
/// Typical: 0.02 - 0.1s
|
||||
/// </summary>
|
||||
public double Delta { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Mô hình động học của motor driver
|
||||
/// First-order system: v(t) = v_cmd × (1 - e^(-(t-δ)/τ))
|
||||
/// </summary>
|
||||
public class MotorDynamicsModel
|
||||
{
|
||||
/// <summary>
|
||||
/// Time constant (τ) - thời gian để motor đạt 63.2% của target velocity
|
||||
/// Đơn vị: giây (s)3
|
||||
/// Typical: 0.1 - 0.5s cho DC motor với driver PID
|
||||
/// </summary>
|
||||
public double Tau { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Pure delay (δ) - độ trễ trước khi motor bắt đầu phản ứng
|
||||
/// Đơn vị: giây (s)
|
||||
/// Bao gồm: communication delay + driver processing
|
||||
/// Typical: 0.02 - 0.1s
|
||||
/// </summary>
|
||||
public double Delta { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Constructor với giá trị mặc định
|
||||
/// </summary>
|
||||
public MotorDynamicsModel()
|
||||
{
|
||||
Tau = 0.3; // 300ms time constant
|
||||
Delta = 0.05f; // 50ms delay
|
||||
}
|
||||
|
||||
public MotorDynamicsModel(MotorDynamicsConfig cog)
|
||||
{
|
||||
Tau = cog.Tau;
|
||||
Delta = cog.Delta;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Predict vận tốc tại thời điểm tương lai
|
||||
/// </summary>
|
||||
/// <param name="vCmd">Velocity command đã gửi</param>
|
||||
/// <param name="vActual">Velocity thực tế hiện tại</param>
|
||||
/// <param name="timeAhead">Thời gian dự đoán về tương lai (s)</param>
|
||||
/// <returns>Vận tốc dự đoán</returns>
|
||||
public double PredictVelocity(double vCmd, double vActual, double timeAhead)
|
||||
{
|
||||
// Nếu thời gian dự đoán < delay
|
||||
// → Motor chưa bắt đầu phản ứng
|
||||
if (timeAhead < Delta)
|
||||
{
|
||||
return vActual;
|
||||
}
|
||||
|
||||
// Thời gian hiệu dụng (sau khi trừ delay)
|
||||
double effectiveTime = timeAhead - Delta;
|
||||
|
||||
// First-order system response
|
||||
// response = 1 - e^(-t/τ)
|
||||
double response = 1.0 - Math.Exp(-effectiveTime / Tau);
|
||||
|
||||
// Velocity prediction
|
||||
// v_future = v_actual + (v_cmd - v_actual) × response
|
||||
double vPredicted = vActual + (vCmd - vActual) * response;
|
||||
|
||||
return vPredicted;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tính settling time (thời gian để đạt 95% target)
|
||||
/// </summary>
|
||||
public double GetSettlingTime()
|
||||
{
|
||||
// 95% response: t = -τ × ln(0.05) ≈ 3τ
|
||||
return Delta + 3.0 * Tau;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tính rise time (thời gian để đạt từ 10% đến 90%)
|
||||
/// </summary>
|
||||
public double GetRiseTime()
|
||||
{
|
||||
// Rise time ≈ 2.2τ
|
||||
return 2.2 * Tau;
|
||||
}
|
||||
|
||||
public override string ToString()
|
||||
{
|
||||
return $"MotorModel(τ={Tau:F3}s, δ={Delta:F3}s, settling={GetSettlingTime():F3}s)";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
namespace RobotNet10.RobotApp.Services.Navigation.CSharp;
|
||||
|
||||
public class PIDConfig
|
||||
{
|
||||
public double Kp { get; set; }
|
||||
public double Ki { get; set; }
|
||||
public double Kd { get; set; }
|
||||
/// <summary>
|
||||
/// Integral chỉ tích lũy khi |error| <= IntegralZone.
|
||||
/// Giá trị 0 = không giới hạn (integral luôn tích lũy).
|
||||
/// </summary>
|
||||
public double IntegralZone { get; set; }
|
||||
}
|
||||
|
||||
public class PID(PIDConfig config)
|
||||
{
|
||||
private double Kp = config.Kp;
|
||||
private double Ki = config.Ki;
|
||||
private double Kd = config.Kd;
|
||||
private double IntegralZone = config.IntegralZone;
|
||||
|
||||
private double _prevError;
|
||||
private double _integral;
|
||||
|
||||
public PID WithKp(double kp)
|
||||
{
|
||||
Kp = kp;
|
||||
return this;
|
||||
}
|
||||
|
||||
public PID WithKi(double ki)
|
||||
{
|
||||
Ki = ki;
|
||||
return this;
|
||||
}
|
||||
|
||||
public PID WithKd(double kd)
|
||||
{
|
||||
Kd = kd;
|
||||
return this;
|
||||
}
|
||||
|
||||
public PID WithIntegralZone(double integralZone)
|
||||
{
|
||||
IntegralZone = integralZone;
|
||||
return this;
|
||||
}
|
||||
|
||||
public double PID_step(double error, double max, double min, double timeSample)
|
||||
{
|
||||
double integralStep = 0.5 * (error + _prevError) * timeSample;
|
||||
|
||||
// Integral Zone: chỉ tích lũy khi |error| nằm trong vùng cho phép
|
||||
bool inIntegralZone = IntegralZone <= 0 || Math.Abs(error) <= IntegralZone;
|
||||
if (inIntegralZone)
|
||||
_integral += integralStep;
|
||||
else
|
||||
_integral = 0;
|
||||
|
||||
double derivative = (error - _prevError) / timeSample;
|
||||
_prevError = error;
|
||||
|
||||
double Out = Kp * error
|
||||
+ Ki * _integral
|
||||
+ Kd * derivative;
|
||||
|
||||
// Anti-windup: hoàn tác integralStep khi output bị bão hòa
|
||||
double clamped = Math.Clamp(Out, min, max);
|
||||
if (clamped != Out && inIntegralZone)
|
||||
_integral -= integralStep;
|
||||
|
||||
return clamped;
|
||||
}
|
||||
|
||||
public void Reset()
|
||||
{
|
||||
_prevError = 0;
|
||||
_integral = 0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,929 @@
|
||||
using RobotNet.VDA5050.Order;
|
||||
using RobotNet10.Common;
|
||||
using RobotNet10.Common.Models;
|
||||
using RobotNet10.NavigationTune.Shared.Models;
|
||||
using RobotNet10.RobotApp.Services.Exceptions;
|
||||
using RobotNet10.RobotApp.Services.Robot;
|
||||
using RobotNet10.RobotApp.Services.Robot.Helper;
|
||||
using RobotNet10.RobotApp.Services.Robot.Models;
|
||||
using RobotNet10.RobotApp.Services.Simulation;
|
||||
using RobotDirection = RobotNet10.RobotApp.Shared.Enums.RobotDirection;
|
||||
|
||||
namespace RobotNet10.RobotApp.Services.Navigation.CSharp;
|
||||
|
||||
/// <summary>
|
||||
/// Configuration cho Pure Pursuit controller
|
||||
/// </summary>
|
||||
public class PurePursuitConfig
|
||||
{
|
||||
/// <summary>
|
||||
/// Lookahead distance minimum (m)
|
||||
/// </summary>
|
||||
public double LookaheadMin { get; set; } = 0.3;
|
||||
|
||||
/// <summary>
|
||||
/// Hệ số tỷ lệ lookahead với vận tốc (s)
|
||||
/// </summary>
|
||||
public double Kdd { get; set; } = 1.0;
|
||||
|
||||
/// <summary>
|
||||
/// Lookahead distance maximum (m)
|
||||
/// </summary>
|
||||
public double LookaheadMax { get; set; } = 2.0;
|
||||
|
||||
/// <summary>
|
||||
/// [LEGACY] Gain cho curvature (nếu cần scale steering)
|
||||
/// Note: Not used in current implementation.
|
||||
/// Replaced by adaptive KCurvature for lookahead adjustment.
|
||||
/// Kept for backward compatibility.
|
||||
/// </summary>
|
||||
public double CurvatureGain { get; set; } = 1.0;
|
||||
|
||||
/// <summary>
|
||||
/// Ngưỡng để coi như đạt waypoint (m)
|
||||
/// </summary>
|
||||
public double WaypointTolerance { get; set; } = 0.1;
|
||||
|
||||
/// <summary>
|
||||
/// Maximum angular velocity during tracking (rad/s)
|
||||
/// </summary>
|
||||
public double MaxAngularVelocity { get; set; } = 1.5;
|
||||
|
||||
/// <summary>
|
||||
/// Path waypoint spacing resolution (meters)
|
||||
/// </summary>
|
||||
public double ResolutionSplit { get; set; } = 0.05;
|
||||
|
||||
#region Adaptive Lookahead Parameters
|
||||
|
||||
/// <summary>
|
||||
/// Goal region distance - start reducing lookahead when closer than this (m)
|
||||
/// Default: 1.5m
|
||||
/// </summary>
|
||||
public double GoalRegionDistance { get; set; } = 1.5;
|
||||
|
||||
/// <summary>
|
||||
/// Curvature adaptation factor (higher = more lookahead reduction on curves)
|
||||
/// Default: 2.0
|
||||
/// </summary>
|
||||
public double KCurvature { get; set; } = 2.0;
|
||||
|
||||
/// <summary>
|
||||
/// Minimum lookahead time ratio (seconds) - for dynamic min limit
|
||||
/// Default: 0.3s
|
||||
/// </summary>
|
||||
public double MinLookaheadTimeRatio { get; set; } = 0.3;
|
||||
|
||||
/// <summary>
|
||||
/// Maximum lookahead time ratio (seconds) - for dynamic max limit
|
||||
/// Default: 2.0s
|
||||
/// </summary>
|
||||
public double MaxLookaheadTimeRatio { get; set; } = 2.0;
|
||||
|
||||
/// <summary>
|
||||
/// Switch to Stanley controller when within this distance to goal (m)
|
||||
/// Default: 0.5m
|
||||
/// </summary>
|
||||
public double FinalApproachThreshold { get; set; } = 1;
|
||||
|
||||
#endregion
|
||||
}
|
||||
|
||||
public class PurePursuit(PurePursuitConfig PurePursuitConfig, StanleyConfig StanleyConfig)
|
||||
{
|
||||
public OrderNode[] OrderNodes = [];
|
||||
public OrderEdge[] OrderEdges = [];
|
||||
public OrderNode? LastOrderNode = null;
|
||||
public List<NavigationNode> Waypoints_Value = [];
|
||||
|
||||
private Dictionary<string, (int start, int end)>? _segmentCache;
|
||||
|
||||
private NavigationNode? Goal;
|
||||
|
||||
private int closesEdgeIndex = 0;
|
||||
private int _currentWaypointAheadIndex = 0; // For Stanley controller
|
||||
private bool _isApproachGoal = false; // Flag for final approach mode
|
||||
|
||||
// Local Planner: approach curve stored as a virtual edge
|
||||
private int _approachWaypointCount = 0;
|
||||
private int _connectionEdgeIndex = 0;
|
||||
private OrderEdge? _approachOrderEdge; // Virtual edge for segment cache lookup
|
||||
private SpaceEdge? _approachSpaceEdge; // Geometry for projection in GetClosesEdges
|
||||
|
||||
public PurePursuit WithPath(Node[] nodes, Edge[] edges, double currentTheta)
|
||||
{
|
||||
if (nodes.Length < 2) throw new SimulationException(RobotErrors.Error1002(nodes.Length));
|
||||
if (edges.Length < 1) throw new SimulationException();
|
||||
if (edges.Length != nodes.Length - 1) throw new SimulationException(RobotErrors.Error1004(nodes.Length, edges.Length));
|
||||
(OrderNodes, OrderEdges) = OrderConverter.Validate(nodes, edges, currentTheta);
|
||||
Waypoints_Value = [.. PathSplit(OrderNodes, OrderEdges)];
|
||||
closesEdgeIndex = 0;
|
||||
_currentWaypointAheadIndex = 0;
|
||||
_isApproachGoal = false;
|
||||
_approachWaypointCount = 0;
|
||||
_connectionEdgeIndex = 0;
|
||||
_approachOrderEdge = null;
|
||||
_approachSpaceEdge = null;
|
||||
BuildSegmentCache();
|
||||
return this;
|
||||
}
|
||||
|
||||
public void ResetTracking()
|
||||
{
|
||||
closesEdgeIndex = 0;
|
||||
_currentWaypointAheadIndex = 0;
|
||||
_isApproachGoal = false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sinh approach waypoints (cubic Bezier) từ vị trí robot đến path rồi prepend vào Waypoints_Value.
|
||||
/// Approach curve CHỈ dựa vào vị trí (X,Y) robot và tangent tại connection point — KHÔNG dùng robotTheta.
|
||||
/// Robot sẽ Rotate tại chỗ đến heading đầu curve trước khi Moving.
|
||||
/// </summary>
|
||||
public ApproachResult GenerateAndPrependApproachPath(
|
||||
double robotX, double robotY, int closestIndex, LocalPlannerConfig config)
|
||||
{
|
||||
if (!config.Enabled || Waypoints_Value.Count < 2)
|
||||
return ApproachResult.Disabled;
|
||||
|
||||
// Bước 1: Tính khoảng cách đến path
|
||||
double distToPath = CalculateDistance(robotX, robotY,
|
||||
Waypoints_Value[closestIndex].X, Waypoints_Value[closestIndex].Y);
|
||||
|
||||
if (distToPath < config.OnPathThreshold)
|
||||
return ApproachResult.AlreadyOnPath;
|
||||
|
||||
if (distToPath > config.MaxApproachDistance)
|
||||
return ApproachResult.TooFarFromPath;
|
||||
|
||||
// Bước 2: Tính mergeDistance (dynamic theo distToPath)
|
||||
double mergeDistance = config.MergeDistanceGain * distToPath;
|
||||
mergeDistance = Math.Clamp(mergeDistance, config.MergeDistanceMin, config.MergeDistanceMax);
|
||||
|
||||
// Bước 3: Tìm connection point trên path
|
||||
int connectionIndex = closestIndex;
|
||||
double accDist = 0;
|
||||
while (accDist < mergeDistance && connectionIndex < Waypoints_Value.Count - 2)
|
||||
{
|
||||
double segLen = CalculateDistance(
|
||||
Waypoints_Value[connectionIndex].X, Waypoints_Value[connectionIndex].Y,
|
||||
Waypoints_Value[connectionIndex + 1].X, Waypoints_Value[connectionIndex + 1].Y);
|
||||
accDist += segLen;
|
||||
connectionIndex++;
|
||||
}
|
||||
var connectionPoint = Waypoints_Value[connectionIndex];
|
||||
|
||||
// Bước 4: Tính path tangent tại connection point
|
||||
double pathTangent;
|
||||
if (connectionIndex < Waypoints_Value.Count - 1)
|
||||
{
|
||||
double tx = Waypoints_Value[connectionIndex + 1].X - connectionPoint.X;
|
||||
double ty = Waypoints_Value[connectionIndex + 1].Y - connectionPoint.Y;
|
||||
pathTangent = Math.Atan2(ty, tx);
|
||||
}
|
||||
else if (connectionIndex > 0)
|
||||
{
|
||||
double tx = connectionPoint.X - Waypoints_Value[connectionIndex - 1].X;
|
||||
double ty = connectionPoint.Y - Waypoints_Value[connectionIndex - 1].Y;
|
||||
pathTangent = Math.Atan2(ty, tx);
|
||||
}
|
||||
else
|
||||
{
|
||||
return ApproachResult.Disabled;
|
||||
}
|
||||
|
||||
// Bước 5: Tính 4 control points cho Cubic Bezier
|
||||
// P0 = robot position, P3 = connection point
|
||||
// P1 = dọc hướng P0→P3 (KHÔNG dùng robotTheta)
|
||||
// P2 = tiếp cận theo pathTangent
|
||||
double p0x = robotX, p0y = robotY;
|
||||
double p3x = connectionPoint.X, p3y = connectionPoint.Y;
|
||||
|
||||
double dist = CalculateDistance(p0x, p0y, p3x, p3y);
|
||||
if (dist < config.ResolutionSplit)
|
||||
return ApproachResult.AlreadyOnPath;
|
||||
|
||||
// Direction P0→P3
|
||||
double dirX = (p3x - p0x) / dist;
|
||||
double dirY = (p3y - p0y) / dist;
|
||||
|
||||
double d1 = config.ControlArmRatioStart * dist;
|
||||
double d2 = config.ControlArmRatioEnd * dist;
|
||||
|
||||
double p1x = p0x + d1 * dirX;
|
||||
double p1y = p0y + d1 * dirY;
|
||||
double p2x = p3x - d2 * Math.Cos(pathTangent);
|
||||
double p2y = p3y - d2 * Math.Sin(pathTangent);
|
||||
|
||||
// Bước 6: Sample waypoints trên curve
|
||||
var approachEdge = new SpaceEdge()
|
||||
{
|
||||
StartX = p0x, StartY = p0y,
|
||||
EndX = p3x, EndY = p3y,
|
||||
ControlPoint1X = p1x, ControlPoint1Y = p1y,
|
||||
ControlPoint2X = p2x, ControlPoint2Y = p2y,
|
||||
Degree = 3
|
||||
};
|
||||
|
||||
double length = SpaceCompute.GetEdgeLength(approachEdge, config.ResolutionSplit);
|
||||
if (length < config.ResolutionSplit)
|
||||
return ApproachResult.AlreadyOnPath;
|
||||
|
||||
double step = config.ResolutionSplit / length;
|
||||
var approachWaypoints = new List<NavigationNode>();
|
||||
|
||||
for (double t = 0; t < 1 - step; t += step)
|
||||
{
|
||||
(double x, double y) = SpaceCompute.BezierPoint(t, approachEdge);
|
||||
approachWaypoints.Add(new NavigationNode
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
NodeId = string.Empty,
|
||||
X = x,
|
||||
Y = y,
|
||||
Direction = connectionPoint.Direction,
|
||||
Speed = connectionPoint.Speed
|
||||
});
|
||||
}
|
||||
|
||||
if (approachWaypoints.Count == 0)
|
||||
return ApproachResult.AlreadyOnPath;
|
||||
|
||||
// Bước 7a: Xác định edge chứa connectionIndex (trước khi modify Waypoints_Value)
|
||||
int connectionEdgeIdx = 0;
|
||||
if (_segmentCache is not null)
|
||||
{
|
||||
for (int i = 0; i < OrderEdges.Length; i++)
|
||||
{
|
||||
if (_segmentCache.TryGetValue(OrderEdges[i].EdgeId, out var range)
|
||||
&& connectionIndex >= range.start && connectionIndex <= range.end)
|
||||
{
|
||||
connectionEdgeIdx = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Bước 7b: Lưu approach curve như 1 virtual edge (cho projection + segment cache)
|
||||
_approachSpaceEdge = approachEdge;
|
||||
_approachOrderEdge = new OrderEdge
|
||||
{
|
||||
EdgeId = "__approach__",
|
||||
Degree = 3,
|
||||
ControlPoint1X = p1x, ControlPoint1Y = p1y,
|
||||
ControlPoint2X = p2x, ControlPoint2Y = p2y,
|
||||
Direction = connectionPoint.Direction,
|
||||
Speed = connectionPoint.Speed,
|
||||
};
|
||||
|
||||
// Bước 7c: Insert approach waypoints ngay trước connectionIndex + xóa phần trước
|
||||
_approachWaypointCount = approachWaypoints.Count;
|
||||
Waypoints_Value.InsertRange(connectionIndex, approachWaypoints);
|
||||
if (connectionIndex > 0)
|
||||
{
|
||||
Waypoints_Value.RemoveRange(0, connectionIndex);
|
||||
}
|
||||
// Kết quả: [approach_0, ..., approach_n, connectionPoint, ..., goal]
|
||||
// 0 _approachWaypointCount
|
||||
|
||||
_connectionEdgeIndex = connectionEdgeIdx;
|
||||
closesEdgeIndex = connectionEdgeIdx;
|
||||
|
||||
// Bước 7d: Rebuild segment cache (approach edge + real edges từ connectionEdge trở đi)
|
||||
BuildSegmentCache(_connectionEdgeIndex);
|
||||
|
||||
return ApproachResult.ApproachGenerated;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Rebuild toàn bộ Waypoints_Value từ OrderNodes/OrderEdges đã lưu.
|
||||
/// Dùng khi cần sinh lại approach từ vị trí mới (thay vì ClearApproachWaypoints).
|
||||
/// Flow: RebuildPath() → check local planner → GenerateAndPrependApproachPath() nếu cần.
|
||||
/// </summary>
|
||||
public void RebuildPath()
|
||||
{
|
||||
string? goalId = Goal?.NodeId;
|
||||
|
||||
Waypoints_Value = [.. PathSplit(OrderNodes, OrderEdges)];
|
||||
_approachWaypointCount = 0;
|
||||
_connectionEdgeIndex = 0;
|
||||
_approachOrderEdge = null;
|
||||
_approachSpaceEdge = null;
|
||||
BuildSegmentCache();
|
||||
|
||||
if (!string.IsNullOrEmpty(goalId))
|
||||
UpdateGoal(goalId);
|
||||
}
|
||||
|
||||
public void UpdateGoal(string goalId)
|
||||
{
|
||||
var goal = Waypoints_Value.FirstOrDefault(n => n.NodeId == goalId);
|
||||
if (goal is not null) Goal = goal;
|
||||
}
|
||||
|
||||
private NavigationNode[] PathSplit(OrderNode[] nodes, OrderEdge[] edges)
|
||||
{
|
||||
List<NavigationNode> navigationNode = [new()
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
NodeId = nodes[0].NodeId,
|
||||
X = nodes[0].X,
|
||||
Y = nodes[0].Y,
|
||||
Theta = nodes[0].Theta,
|
||||
Direction = edges[0].Direction,
|
||||
Speed = edges[0].Speed,
|
||||
}];
|
||||
foreach (var edge in edges)
|
||||
{
|
||||
var startNode = nodes.FirstOrDefault(n => n.NodeId == edge.StartNodeId);
|
||||
var endNode = nodes.FirstOrDefault(n => n.NodeId == edge.EndNodeId);
|
||||
if (startNode is null) throw new PathPlannerException(RobotErrors.Error1008(edge.EdgeId, edge.StartNodeId));
|
||||
if (endNode is null) throw new PathPlannerException(RobotErrors.Error1009(edge.EdgeId, edge.EndNodeId));
|
||||
|
||||
var spaceEdge = new SpaceEdge()
|
||||
{
|
||||
StartX = startNode.X,
|
||||
StartY = startNode.Y,
|
||||
EndX = endNode.X,
|
||||
EndY = endNode.Y,
|
||||
ControlPoint1X = edge.ControlPoint1X ?? 0,
|
||||
ControlPoint1Y = edge.ControlPoint1Y ?? 0,
|
||||
ControlPoint2X = edge.ControlPoint2X ?? 0,
|
||||
ControlPoint2Y = edge.ControlPoint2Y ?? 0,
|
||||
Degree = edge.Degree,
|
||||
};
|
||||
|
||||
double length = SpaceCompute.GetEdgeLength(spaceEdge, PurePursuitConfig.ResolutionSplit);
|
||||
if (length <= 0) continue;
|
||||
double step = PurePursuitConfig.ResolutionSplit / length;
|
||||
|
||||
for (double t = step; t <= 1 - step; t += step)
|
||||
{
|
||||
(double x, double y) = SpaceCompute.BezierPoint(t, spaceEdge);
|
||||
navigationNode.Add(new()
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
NodeId = string.Empty,
|
||||
X = x,
|
||||
Y = y,
|
||||
Theta = null,
|
||||
Direction = edge.Direction,
|
||||
Speed = edge.Speed,
|
||||
});
|
||||
}
|
||||
navigationNode.Add(new()
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
NodeId = endNode.NodeId,
|
||||
X = endNode.X,
|
||||
Y = endNode.Y,
|
||||
Theta = endNode.Theta,
|
||||
Direction = edge.Direction,
|
||||
Speed = edge.Speed,
|
||||
});
|
||||
}
|
||||
return [.. navigationNode];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Build segment cache mapping EdgeId → (startWaypointIdx, endWaypointIdx).
|
||||
/// Approach edge (nếu có) được thêm vào cache trước, sau đó build các real edges từ fromEdgeIndex.
|
||||
/// </summary>
|
||||
private void BuildSegmentCache(int fromEdgeIndex = 0)
|
||||
{
|
||||
_segmentCache = [];
|
||||
|
||||
// Thêm approach edge vào cache nếu có
|
||||
if (_approachOrderEdge is not null && _approachWaypointCount > 0)
|
||||
{
|
||||
_segmentCache[_approachOrderEdge.EdgeId] = (0, _approachWaypointCount);
|
||||
}
|
||||
|
||||
for (int i = fromEdgeIndex; i < OrderEdges.Length; i++)
|
||||
{
|
||||
var edge = OrderEdges[i];
|
||||
int waypointStartIdx = Waypoints_Value.FindIndex(n => n.NodeId == edge.StartNodeId);
|
||||
int waypointEndIdx = Waypoints_Value.FindIndex(n => n.NodeId == edge.EndNodeId);
|
||||
|
||||
// Edge đầu tiên: start node có thể đã bị xóa
|
||||
// → lấy end của approach edge nếu có, hoặc 0
|
||||
if (waypointStartIdx == -1)
|
||||
{
|
||||
if (i == fromEdgeIndex)
|
||||
waypointStartIdx = _approachOrderEdge is not null
|
||||
? _segmentCache[_approachOrderEdge.EdgeId].end
|
||||
: 0;
|
||||
else
|
||||
waypointStartIdx = _segmentCache[OrderEdges[i - 1].EdgeId].end;
|
||||
}
|
||||
if (waypointEndIdx == -1) waypointEndIdx = Waypoints_Value.Count - 1;
|
||||
|
||||
if (waypointStartIdx > waypointEndIdx) throw new NavigationException($"Waypoint has invalid range for edge {edge.EdgeId}: start={waypointStartIdx}, end={waypointEndIdx}");
|
||||
|
||||
_segmentCache[edge.EdgeId] = (waypointStartIdx, waypointEndIdx);
|
||||
}
|
||||
}
|
||||
|
||||
private (OrderEdge edge, double time) GetClosesEdges(double x, double y)
|
||||
{
|
||||
double minDistance = double.MaxValue;
|
||||
OrderEdge? edgesResult = null;
|
||||
double prjTime = 0;
|
||||
|
||||
// Project lên approach edge nếu robot chưa vượt qua connection edge
|
||||
if (_approachSpaceEdge is not null && _approachOrderEdge is not null
|
||||
&& closesEdgeIndex <= _connectionEdgeIndex)
|
||||
{
|
||||
(_, _, var approachDist, double approachTime) = SpaceCompute.GetProjectionOnEdge(x, y, _approachSpaceEdge);
|
||||
if (approachDist < minDistance)
|
||||
{
|
||||
minDistance = approachDist;
|
||||
edgesResult = _approachOrderEdge;
|
||||
prjTime = approachTime;
|
||||
}
|
||||
}
|
||||
|
||||
// Project lên các real edges (từ closesEdgeIndex)
|
||||
for (int i = closesEdgeIndex; i < OrderEdges.Length; i++)
|
||||
{
|
||||
var startNode = OrderNodes.FirstOrDefault(node => node.NodeId == OrderEdges[i].StartNodeId);
|
||||
var endNode = OrderNodes.FirstOrDefault(node => node.NodeId == OrderEdges[i].EndNodeId);
|
||||
if (startNode is null || endNode is null) continue;
|
||||
|
||||
(_, _, var distance, double time) = SpaceCompute.GetProjectionOnEdge(x, y, new()
|
||||
{
|
||||
StartX = startNode.X,
|
||||
StartY = startNode.Y,
|
||||
EndX = endNode.X,
|
||||
EndY = endNode.Y,
|
||||
Degree = OrderEdges[i].Degree,
|
||||
ControlPoint1X = OrderEdges[i].ControlPoint1X ?? 0,
|
||||
ControlPoint1Y = OrderEdges[i].ControlPoint1Y ?? 0,
|
||||
ControlPoint2X = OrderEdges[i].ControlPoint2X ?? 0,
|
||||
ControlPoint2Y = OrderEdges[i].ControlPoint2Y ?? 0,
|
||||
});
|
||||
if (distance < minDistance)
|
||||
{
|
||||
minDistance = distance;
|
||||
edgesResult = OrderEdges[i];
|
||||
prjTime = time;
|
||||
closesEdgeIndex = i;
|
||||
}
|
||||
}
|
||||
return (edgesResult ?? OrderEdges[closesEdgeIndex], prjTime);
|
||||
}
|
||||
|
||||
public (NavigationNode node, int index) OnNode(double x, double y)
|
||||
{
|
||||
// Edge-based projection thống nhất cho cả approach edge và real edges
|
||||
(var closeEdge, double prjTime) = GetClosesEdges(x, y);
|
||||
(var startNodeIdx, var endNodeIdx) = _segmentCache is not null && _segmentCache.TryGetValue(closeEdge.EdgeId, out var cached)
|
||||
? cached
|
||||
: (0, Waypoints_Value.Count - 1);
|
||||
int onNodeIndex = (int)(Math.Abs(endNodeIdx - startNodeIdx) * prjTime) + startNodeIdx;
|
||||
|
||||
return (Waypoints_Value[onNodeIndex], onNodeIndex);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Calculate adaptive lookahead distance based on velocity, confidence, distance to goal, and path curvature
|
||||
/// Lookahead adapts to:
|
||||
/// 1. Velocity (faster = look further ahead)
|
||||
/// 2. Distance to goal (near goal = shorter lookahead for precision)
|
||||
/// 3. Path curvature (sharp curves = shorter lookahead for tighter tracking)
|
||||
/// 4. Confidence (low confidence = shorter lookahead for safety)
|
||||
/// 5. Velocity-based dynamic time limits
|
||||
/// </summary>
|
||||
private double GetLookaheadDistance(double vHybrid, double robotX, double robotY, int closestIndex)
|
||||
{
|
||||
// 1. Base lookahead from velocity
|
||||
double baseLookahead = PurePursuitConfig.LookaheadMin + PurePursuitConfig.Kdd * Math.Abs(vHybrid);
|
||||
|
||||
// 2. Distance-to-goal adaptation
|
||||
double distanceToGoal = CalculateDistanceToGoal(robotX, robotY);
|
||||
double goalFactor = 1.0;
|
||||
if (distanceToGoal < PurePursuitConfig.GoalRegionDistance)
|
||||
{
|
||||
// Gradually reduce lookahead as we approach goal
|
||||
// At goal: factor = 0.5, At GoalRegionDistance: factor = 1.0
|
||||
goalFactor = 0.5 + 0.5 * (distanceToGoal / PurePursuitConfig.GoalRegionDistance);
|
||||
}
|
||||
|
||||
// 3. Curvature adaptation
|
||||
double curvature = CalculateCurvature(closestIndex);
|
||||
// curvatureFactor ranges from 1.0 (straight) to ~0.33 (very sharp curve with KCurvature=2.0)
|
||||
double curvatureFactor = 1.0 / (1.0 + PurePursuitConfig.KCurvature * curvature);
|
||||
|
||||
// 5. Combine all factors
|
||||
double adaptiveLookahead = baseLookahead * goalFactor * curvatureFactor;
|
||||
|
||||
double minLookahead = PurePursuitConfig.LookaheadMin;
|
||||
double maxLookahead = PurePursuitConfig.LookaheadMax;
|
||||
|
||||
if (distanceToGoal < PurePursuitConfig.GoalRegionDistance && Math.Abs(vHybrid) > 0.0)
|
||||
{
|
||||
// 6. Apply velocity-based dynamic limits
|
||||
// Minimum: look at least 0.3 seconds ahead or LookaheadMin (whichever is larger)
|
||||
minLookahead = Math.Max(PurePursuitConfig.LookaheadMin, Math.Abs(vHybrid) * PurePursuitConfig.MinLookaheadTimeRatio);
|
||||
|
||||
// Maximum: look at most 2 seconds ahead or LookaheadMax (whichever is smaller)
|
||||
maxLookahead = Math.Min(PurePursuitConfig.LookaheadMax, Math.Abs(vHybrid) * PurePursuitConfig.MaxLookaheadTimeRatio);
|
||||
|
||||
// Ensure min < max
|
||||
if (minLookahead > maxLookahead)
|
||||
minLookahead = maxLookahead;
|
||||
}
|
||||
|
||||
adaptiveLookahead = Math.Clamp(adaptiveLookahead, minLookahead, maxLookahead);
|
||||
|
||||
if (double.IsNaN(adaptiveLookahead) || adaptiveLookahead <= 0)
|
||||
{
|
||||
adaptiveLookahead = PurePursuitConfig.LookaheadMin;
|
||||
}
|
||||
|
||||
return adaptiveLookahead;
|
||||
}
|
||||
|
||||
public (double linearVel, double angularVel) PurePursuit_step(double X_Ref,
|
||||
double Y_Ref,
|
||||
double Angle_Ref,
|
||||
double actualLinearVelocity,
|
||||
double maxLinearVelocity)
|
||||
{
|
||||
if (Waypoints_Value is null || Waypoints_Value.Count < 2)
|
||||
throw new NavigationException("NAV PP Waypoint not yet set");
|
||||
|
||||
// 1. Get closest waypoint (KEEP ORIGINAL LOGIC)
|
||||
var (onNode, index) = OnNode(X_Ref, Y_Ref);
|
||||
if (onNode is null || Goal is null)
|
||||
throw new NavigationException("NAV PP cannot get projection node");
|
||||
|
||||
// 2. Calculate adaptive lookahead distance (UPGRADED)
|
||||
double lookaheadDistance = GetLookaheadDistance(actualLinearVelocity, X_Ref, Y_Ref, index);
|
||||
|
||||
// 3. Find target point with INTERPOLATION (UPGRADED)
|
||||
NavigationNode? targetPoint = FindTargetPoint(index, lookaheadDistance);
|
||||
targetPoint ??= Goal;
|
||||
|
||||
// 4. Apply speed limit from target point if available
|
||||
double linearVel = maxLinearVelocity;
|
||||
double? targetSpeed = targetPoint.Speed;
|
||||
|
||||
if (targetSpeed.HasValue && targetSpeed.Value > 0)
|
||||
{
|
||||
linearVel = Math.Min(maxLinearVelocity, targetSpeed.Value);
|
||||
}
|
||||
|
||||
// 5. Check for final approach (PREPARATION FOR STANLEY)
|
||||
double distanceToGoal = CalculateDistanceToGoal(X_Ref, Y_Ref);
|
||||
if (targetPoint.Id == Goal.Id || distanceToGoal <= PurePursuitConfig.FinalApproachThreshold)
|
||||
{
|
||||
_isApproachGoal = true;
|
||||
}
|
||||
|
||||
// 6. Normalize theta for backward (SIMPLIFIED)
|
||||
bool isBackward = onNode.Direction == RobotDirection.BACKWARD;
|
||||
if (isBackward) Angle_Ref += Math.PI;
|
||||
Angle_Ref = NormalizeAngle(Angle_Ref);
|
||||
|
||||
// 7. Switch to Stanley when approaching goal (STANLEY INTEGRATION)
|
||||
if (_isApproachGoal)
|
||||
{
|
||||
var (linear, angular) = FinalApproachController(X_Ref, Y_Ref, Angle_Ref, Goal, actualLinearVelocity, linearVel, isBackward);
|
||||
return (linear, angular);
|
||||
}
|
||||
|
||||
// 8. Calculate angle to target
|
||||
var dx = targetPoint.X - X_Ref;
|
||||
var dy = targetPoint.Y - Y_Ref;
|
||||
var alpha = Math.Atan2(dy, dx) - Angle_Ref;
|
||||
|
||||
// Normalize alpha to [-π, π] (SIMPLIFIED)
|
||||
alpha = NormalizeAngle(alpha);
|
||||
|
||||
// 9. Pure Pursuit formula: ω = 2 * v * sin(α) / L
|
||||
var angularVelocity = 2.0 * Math.Abs(actualLinearVelocity) * Math.Sin(alpha) / lookaheadDistance;
|
||||
|
||||
// 10. Clamp to max angular velocity
|
||||
if (Math.Abs(angularVelocity) > PurePursuitConfig.MaxAngularVelocity)
|
||||
{
|
||||
angularVelocity = Math.Sign(angularVelocity) * PurePursuitConfig.MaxAngularVelocity;
|
||||
}
|
||||
|
||||
// 11. Apply direction sign to velocities
|
||||
if (isBackward)
|
||||
{
|
||||
linearVel = -linearVel;
|
||||
}
|
||||
|
||||
Console.WriteLine($"PP: Target=({targetPoint.X:F3},{targetPoint.Y:F3}), " +
|
||||
$"Pose=({X_Ref:F3},{Y_Ref:F3},{Angle_Ref * 180 / Math.PI:F2}°), " +
|
||||
$"Look={lookaheadDistance:F3}, Alpha={alpha * 180 / Math.PI:F2}°, " +
|
||||
$"LVel={linearVel:F3}, AngVel={angularVelocity:F3}");
|
||||
|
||||
return (linearVel, angularVelocity);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Stanley-based final approach controller
|
||||
/// When robot enters goal region (IsApproachGoal), uses Stanley algorithm for precise CTE-based tracking
|
||||
/// </summary>
|
||||
private (double linearVel, double angularVel) FinalApproachController(
|
||||
double robotX,
|
||||
double robotY,
|
||||
double robotTheta,
|
||||
NavigationNode goal,
|
||||
double actualLinearVelocity,
|
||||
double maxLinearVelocity,
|
||||
bool isBackward)
|
||||
{
|
||||
// Calculate front axle position
|
||||
double frontX = robotX + StanleyConfig.WheelBase * Math.Cos(robotTheta);
|
||||
double frontY = robotY + StanleyConfig.WheelBase * Math.Sin(robotTheta);
|
||||
|
||||
// Find closest point on path to front axle
|
||||
var (closestPoint, closestIndex) = GetClosestAheadWaypoint(frontX, frontY);
|
||||
|
||||
// Calculate heading at closest point (path direction)
|
||||
double pathHeading = CalculatePathHeading(closestIndex);
|
||||
|
||||
// Calculate cross-track error (signed distance from front axle to path)
|
||||
double crossTrackError = CalculateStanleyCrossTrackError(frontX, frontY, closestPoint, pathHeading);
|
||||
if (isBackward) crossTrackError = -crossTrackError;
|
||||
|
||||
// Calculate heading error (path heading - robot heading)
|
||||
double headingError = NormalizeAngle(pathHeading - robotTheta);
|
||||
|
||||
// Calculate curvature at closest point (for feedforward)
|
||||
double curvature = 0;
|
||||
if (StanleyConfig.EnableCurvatureFeedforward)
|
||||
{
|
||||
curvature = CalculateCurvature(closestIndex);
|
||||
}
|
||||
|
||||
// Adaptive K gain: increase when close to goal for tighter tracking
|
||||
double distanceToGoal = CalculateDistance(robotX, robotY, goal.X, goal.Y);
|
||||
double adaptiveK = StanleyConfig.K;
|
||||
if (distanceToGoal < StanleyConfig.GoalApproachDistance)
|
||||
{
|
||||
// Linearly increase K from K to K*GoalGainMultiplier as distance decreases
|
||||
double approachRatio = 1.0 - (distanceToGoal / StanleyConfig.GoalApproachDistance);
|
||||
adaptiveK = StanleyConfig.K * (1.0 + approachRatio * (StanleyConfig.GoalGainMultiplier - 1.0));
|
||||
}
|
||||
|
||||
// Stanley formula: δ = ψ + arctan(K × e / (v + Ks))
|
||||
double crossTrackTerm = Math.Atan2(adaptiveK * crossTrackError, Math.Abs(actualLinearVelocity) + StanleyConfig.Ks);
|
||||
|
||||
// Add curvature feedforward if enabled
|
||||
double curvatureTerm = 0;
|
||||
if (StanleyConfig.EnableCurvatureFeedforward && curvature != 0)
|
||||
{
|
||||
curvatureTerm = StanleyConfig.KCurvatureFF * Math.Atan(curvature * StanleyConfig.WheelBase);
|
||||
}
|
||||
|
||||
// Total steering angle
|
||||
double steeringAngle = headingError + crossTrackTerm + curvatureTerm;
|
||||
|
||||
// Clamp to maximum steering angle
|
||||
steeringAngle = Math.Clamp(steeringAngle, -StanleyConfig.MaxSteeringAngle, StanleyConfig.MaxSteeringAngle);
|
||||
|
||||
// Convert steering angle to angular velocity using bicycle model
|
||||
double angularVelocity = (actualLinearVelocity * Math.Tan(steeringAngle)) / StanleyConfig.WheelBase;
|
||||
|
||||
// Clamp angular velocity for final approach
|
||||
angularVelocity = Math.Clamp(angularVelocity, -StanleyConfig.MaxAngularVelocity, StanleyConfig.MaxAngularVelocity);
|
||||
|
||||
// Apply direction to velocities
|
||||
double linearVel = maxLinearVelocity;
|
||||
if (isBackward)
|
||||
{
|
||||
linearVel = -linearVel;
|
||||
}
|
||||
|
||||
// Debug output
|
||||
Console.WriteLine($"FA-Stanley: Front=({frontX:F3},{frontY:F3}), Closest=({closestPoint.X:F3},{closestPoint.Y:F3}), " +
|
||||
$"Pose=({robotX:F3},{robotY:F3},{robotTheta * 180 / Math.PI:F1}°), " +
|
||||
$"CTE={crossTrackError:F3}m, HeadErr={headingError * 180 / Math.PI:F1}°, " +
|
||||
$"Curv={curvature:F3}, SteerAng={steeringAngle * 180 / Math.PI:F1}°, " +
|
||||
$"K={adaptiveK:F2}, DTG={distanceToGoal:F3}m, " +
|
||||
$"LVel={linearVel:F3}, AnVel={angularVelocity:F3}");
|
||||
|
||||
return (linearVel, angularVelocity);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Find target point at lookahead distance from current index with interpolation
|
||||
/// This provides smooth target point selection instead of discrete waypoints
|
||||
/// Speed information is interpolated to provide accurate future speed limit
|
||||
/// </summary>
|
||||
private NavigationNode? FindTargetPoint(int startIndex, double lookaheadDistance)
|
||||
{
|
||||
if (startIndex >= Waypoints_Value.Count - 1)
|
||||
return Goal;
|
||||
|
||||
double accumulatedDistance = 0;
|
||||
|
||||
for (int i = startIndex; i < Waypoints_Value.Count - 1; i++)
|
||||
{
|
||||
double dx = Waypoints_Value[i + 1].X - Waypoints_Value[i].X;
|
||||
double dy = Waypoints_Value[i + 1].Y - Waypoints_Value[i].Y;
|
||||
double segmentLength = Math.Sqrt(dx * dx + dy * dy);
|
||||
|
||||
if (accumulatedDistance + segmentLength >= lookaheadDistance)
|
||||
{
|
||||
// Interpolate within this segment
|
||||
double t = (lookaheadDistance - accumulatedDistance) / segmentLength;
|
||||
|
||||
// Interpolate speed if both waypoints have speed info
|
||||
double? interpolatedSpeed;
|
||||
if (Waypoints_Value[i].Speed is { } speed1 && Waypoints_Value[i + 1].Speed is { } speed2)
|
||||
{
|
||||
// Linear interpolation of speed limit
|
||||
interpolatedSpeed = speed1 + t * (speed2 - speed1);
|
||||
}
|
||||
else
|
||||
{
|
||||
// Use next waypoint's speed if available (upcoming constraint)
|
||||
interpolatedSpeed = Waypoints_Value[i + 1].Speed ?? Waypoints_Value[i].Speed;
|
||||
}
|
||||
|
||||
return new NavigationNode
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
NodeId = string.Empty,
|
||||
X = Waypoints_Value[i].X + t * dx,
|
||||
Y = Waypoints_Value[i].Y + t * dy,
|
||||
Theta = null,
|
||||
Direction = Waypoints_Value[i].Direction,
|
||||
Speed = interpolatedSpeed // Preserve speed limit for lookahead
|
||||
};
|
||||
}
|
||||
|
||||
accumulatedDistance += segmentLength;
|
||||
}
|
||||
|
||||
return Goal;
|
||||
}
|
||||
|
||||
#region Helper Methods
|
||||
|
||||
/// <summary>
|
||||
/// Calculate distance between two points
|
||||
/// </summary>
|
||||
private static double CalculateDistance(double x1, double y1, double x2, double y2)
|
||||
{
|
||||
double dx = x2 - x1;
|
||||
double dy = y2 - y1;
|
||||
return Math.Sqrt(dx * dx + dy * dy);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Normalize angle to [-π, π]
|
||||
/// </summary>
|
||||
private static double NormalizeAngle(double angle)
|
||||
{
|
||||
while (angle > Math.PI) angle -= 2 * Math.PI;
|
||||
while (angle < -Math.PI) angle += 2 * Math.PI;
|
||||
return angle;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Calculate distance from robot to goal point
|
||||
/// </summary>
|
||||
private double CalculateDistanceToGoal(double robotX, double robotY)
|
||||
{
|
||||
if (Goal == null)
|
||||
return double.MaxValue;
|
||||
|
||||
double dx = Goal.X - robotX;
|
||||
double dy = Goal.Y - robotY;
|
||||
return Math.Sqrt(dx * dx + dy * dy);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Calculate path curvature at given waypoint index using 3-point circle fitting (Menger curvature)
|
||||
/// Returns curvature in 1/meters (larger value = sharper curve)
|
||||
/// </summary>
|
||||
private double CalculateCurvature(int index)
|
||||
{
|
||||
// Need at least 3 points for curvature calculation
|
||||
if (Waypoints_Value.Count < 3 || index <= 0 || index >= Waypoints_Value.Count - 1)
|
||||
return 0.0;
|
||||
|
||||
var p1 = Waypoints_Value[index - 1];
|
||||
var p2 = Waypoints_Value[index];
|
||||
var p3 = Waypoints_Value[index + 1];
|
||||
|
||||
// Calculate vectors
|
||||
double dx1 = p2.X - p1.X;
|
||||
double dy1 = p2.Y - p1.Y;
|
||||
double dx2 = p3.X - p2.X;
|
||||
double dy2 = p3.Y - p2.Y;
|
||||
|
||||
// Cross product magnitude (2 * triangle area)
|
||||
double cross = Math.Abs(dx1 * dy2 - dy1 * dx2);
|
||||
|
||||
// Side lengths of triangle
|
||||
double a = Math.Sqrt(dx1 * dx1 + dy1 * dy1);
|
||||
double b = Math.Sqrt(dx2 * dx2 + dy2 * dy2);
|
||||
double c = Math.Sqrt((p3.X - p1.X) * (p3.X - p1.X) + (p3.Y - p1.Y) * (p3.Y - p1.Y));
|
||||
|
||||
// Menger curvature formula: k = 4 * Area / (a * b * c)
|
||||
// Area of triangle = cross / 2, so k = 2 * cross / (a * b * c)
|
||||
double curvature = 2.0 * cross / (a * b * c + 1e-9); // Add small epsilon to avoid division by zero
|
||||
|
||||
return curvature;
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Stanley Helper Methods (for FinalApproachController)
|
||||
|
||||
/// <summary>
|
||||
/// Get closest waypoint ahead to given position (for Stanley front axle tracking)
|
||||
/// </summary>
|
||||
private (NavigationNode point, int index) GetClosestAheadWaypoint(double x, double y)
|
||||
{
|
||||
if (Waypoints_Value.Count == 0)
|
||||
throw new InvalidOperationException("Path not set");
|
||||
|
||||
double minDistance = double.MaxValue;
|
||||
int closestIndex = 0;
|
||||
|
||||
// Start search from current index for efficiency
|
||||
for (int i = _currentWaypointAheadIndex; i < Waypoints_Value.Count; i++)
|
||||
{
|
||||
double distance = CalculateDistance(x, y, Waypoints_Value[i].X, Waypoints_Value[i].Y);
|
||||
|
||||
if (distance < minDistance)
|
||||
{
|
||||
minDistance = distance;
|
||||
closestIndex = i;
|
||||
}
|
||||
}
|
||||
|
||||
// Also check previous waypoints in case robot moved backwards
|
||||
for (int i = 0; i < _currentWaypointAheadIndex; i++)
|
||||
{
|
||||
double distance = CalculateDistance(x, y, Waypoints_Value[i].X, Waypoints_Value[i].Y);
|
||||
|
||||
if (distance < minDistance)
|
||||
{
|
||||
minDistance = distance;
|
||||
closestIndex = i;
|
||||
}
|
||||
}
|
||||
|
||||
_currentWaypointAheadIndex = closestIndex;
|
||||
return (Waypoints_Value[closestIndex], closestIndex);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Calculate path heading at given waypoint index (for Stanley)
|
||||
/// Uses current point and next point to determine direction
|
||||
/// </summary>
|
||||
private double CalculatePathHeading(int index)
|
||||
{
|
||||
if (index >= Waypoints_Value.Count - 1)
|
||||
{
|
||||
// Last point - use previous segment direction
|
||||
if (index > 0)
|
||||
{
|
||||
double dx = Waypoints_Value[index].X - Waypoints_Value[index - 1].X;
|
||||
double dy = Waypoints_Value[index].Y - Waypoints_Value[index - 1].Y;
|
||||
return Math.Atan2(dy, dx);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Use current to next point
|
||||
double dxNext = Waypoints_Value[index + 1].X - Waypoints_Value[index].X;
|
||||
double dyNext = Waypoints_Value[index + 1].Y - Waypoints_Value[index].Y;
|
||||
return Math.Atan2(dyNext, dxNext);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Calculate signed cross-track error (for Stanley)
|
||||
/// Positive: front axle is to the left of path
|
||||
/// Negative: front axle is to the right of path
|
||||
/// </summary>
|
||||
private static double CalculateStanleyCrossTrackError(double frontX, double frontY, NavigationNode closestPoint, double pathHeading)
|
||||
{
|
||||
// Vector from closest point to front axle
|
||||
double dx = frontX - closestPoint.X;
|
||||
double dy = frontY - closestPoint.Y;
|
||||
|
||||
// Path direction vector
|
||||
double pathDx = Math.Cos(pathHeading);
|
||||
double pathDy = Math.Sin(pathHeading);
|
||||
|
||||
// Cross product to get signed perpendicular distance
|
||||
// positive = left, negative = right
|
||||
double crossTrackError = dx * pathDy - dy * pathDx;
|
||||
|
||||
return crossTrackError;
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
}
|
||||
@@ -0,0 +1,457 @@
|
||||
using RobotNet10.CANOpen.CiA402.Enums;
|
||||
using RobotNet10.RobotApp.Motion;
|
||||
using RobotNet10.RobotApp.Services.ConfigManager;
|
||||
|
||||
namespace RobotNet10.RobotApp.Services.Navigation.CSharp;
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Configuration cho signal processing
|
||||
/// </summary>
|
||||
public class VelocitySignalProcessingConfig
|
||||
{
|
||||
/// <summary>
|
||||
/// Hệ số lọc cho encoder velocity
|
||||
/// Giá trị nhỏ (0.1-0.2): Smooth nhưng lag
|
||||
/// Giá trị lớn (0.3-0.4): Responsive nhưng nhiễu
|
||||
/// </summary>
|
||||
public double AlphaFilter { get; set; } = 0.3;
|
||||
|
||||
/// <summary>
|
||||
/// Ngưỡng phát hiện encoder nhiễu (m/s)
|
||||
/// Nếu thay đổi vận tốc > threshold trong 1 cycle → có thể nhiễu
|
||||
/// </summary>
|
||||
public double NoiseThreshold { get; set; } = 0.5;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Configuration cho velocity estimator
|
||||
/// </summary>
|
||||
public class VelocityEstimatorConfig
|
||||
{
|
||||
// Blend ratio limits
|
||||
public double MinBlendRatio { get; set; } = 0.15f;
|
||||
public double MaxBlendRatio { get; set; } = 0.8f;
|
||||
public double DefaultBlendRatio { get; set; } = 0.6;
|
||||
|
||||
// Adaptive blending thresholds
|
||||
public double GoodTrackingThreshold { get; set; } = 0.12f; // < 10% error
|
||||
public double ModerateTrackingThreshold { get; set; } = 0.3; // < 30% error
|
||||
|
||||
// Blend ratios for different tracking qualities
|
||||
public double GoodTrackingBlend { get; set; } = 0.7;
|
||||
public double ModerateTrackingBlend { get; set; } = 0.5;
|
||||
public double PoorTrackingBlend { get; set; } = 0.25f;
|
||||
|
||||
// Model confidence decay
|
||||
public double ConfidenceDecayRate { get; set; } = 0.95f;
|
||||
public double MinConfidence { get; set; } = 0.3;
|
||||
}
|
||||
|
||||
public class VelocityController(IInverseKinematics InverseKinematic,
|
||||
OdometryService odometryService,
|
||||
IRobotConfiguration RobtoConfiguration,
|
||||
INavigationConfig NavigationConfig,
|
||||
ILogger<VelocityController> _logger) : IVelocityController
|
||||
{
|
||||
public (double Linear, double Angular) ActualVelocity => GetCurrentVel();
|
||||
public (double Linear, double Angular) RawVelocity => GetRawCurrentVel();
|
||||
|
||||
// vận tốc tính toán m/s và rad/s đối với vận tốc góc
|
||||
private double _rightVelCmd = 0;
|
||||
private double _leftVelCmd = 0;
|
||||
private double _oldRightVel = 0;
|
||||
private double _oldLeftVel = 0;
|
||||
|
||||
private MotorDynamicsConfig _motorDynamicsConifg = new();
|
||||
private MotorDynamicsModel _motorDynamicsModel = new();
|
||||
private VelocityEstimatorConfig _estimatorConfig = new();
|
||||
public PurePursuitConfig _purePursuitConfig = new();
|
||||
private VelocitySignalProcessingConfig _signalConfig = new();
|
||||
private readonly CircularBuffer<double> _predictionErrors = new(20);
|
||||
|
||||
private double _currentConfidence = 1.0;
|
||||
private readonly double wheelBase = RobtoConfiguration.GetRobotPhysicalConfig().WheelBase;
|
||||
private int _ensureIKReadyCounter = 0; // Counter for logging throttling
|
||||
|
||||
public void SetVelocity(double linearVel, double angularVel)
|
||||
{
|
||||
// InverseKinematic.SetVelocity not available - commented out
|
||||
// InverseKinematic.SetVelocity(new()
|
||||
// {
|
||||
// Linear = new(){
|
||||
// X = linearVel,
|
||||
// Y = 0,
|
||||
// },
|
||||
// Angular = new(){
|
||||
// Z = angularVel,
|
||||
// },
|
||||
// });
|
||||
_leftVelCmd = linearVel - (wheelBase / 2) * angularVel;
|
||||
_rightVelCmd = linearVel + (wheelBase / 2) * angularVel;
|
||||
}
|
||||
public (double linearVel, double angularVel) GetRawCurrentVel()
|
||||
{
|
||||
try
|
||||
{
|
||||
var odom = odometryService.CurrentOdometry;
|
||||
double vActual = odom.Twist.Twist.Linear.X;
|
||||
double omegaActual = odom.Twist.Twist.Angular.Z;
|
||||
return (vActual, omegaActual);
|
||||
}
|
||||
catch { return (0, 0); }
|
||||
}
|
||||
|
||||
public (double linearVel, double angularVel) GetCurrentVel()
|
||||
{
|
||||
try
|
||||
{
|
||||
var odom = odometryService.CurrentOdometry;
|
||||
var vActual = odom.Twist.Twist.Linear.X;
|
||||
var omegaActual = odom.Twist.Twist.Angular.Z;
|
||||
|
||||
// Convert linear/angular back to left/right wheel velocities for the estimator
|
||||
_oldLeftVel = vActual - (wheelBase / 2) * omegaActual;
|
||||
_oldRightVel = vActual + (wheelBase / 2) * omegaActual;
|
||||
|
||||
return Estimate(_oldLeftVel, _oldRightVel, _leftVelCmd, _rightVelCmd, wheelBase);
|
||||
}
|
||||
catch { return (0, 0); }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Exponential Moving Average (EMA) Low-Pass Filter
|
||||
/// </summary>
|
||||
/// <param name="newValue">Giá trị mới từ sensor</param>
|
||||
/// <param name="oldValue">Giá trị đã lọc trước đó</param>
|
||||
/// <param name="alpha">Hệ số lọc (0-1). Càng nhỏ càng smooth, càng lớn càng responsive</param>
|
||||
/// <returns>Giá trị sau khi lọc</returns>
|
||||
private static double LowPassFilter(double newValue, double oldValue, double alpha)
|
||||
{
|
||||
// Validate alpha
|
||||
if (alpha < 0 || alpha > 1)
|
||||
{
|
||||
throw new ArgumentException("Alpha must be between 0 and 1", nameof(alpha));
|
||||
}
|
||||
|
||||
return alpha * newValue + (1.0 - alpha) * oldValue;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// MAIN FUNCTION: Estimate velocity
|
||||
/// </summary>
|
||||
private (double linearVel, double angularVel) Estimate(
|
||||
double vLeftActual, // Từ encoder (filtered)
|
||||
double vRightActual, // Từ encoder (filtered)
|
||||
double vLeftCmdPrev, // Command từ cycle trước
|
||||
double vRightCmdPrev, // Command từ cycle trước
|
||||
double wheelbase)
|
||||
{
|
||||
// 1. Tính vận tốc actual (linear & angular)
|
||||
double vActual = (vLeftActual + vRightActual) / 2.0;
|
||||
double omegaActual = (vRightActual - vLeftActual) / wheelbase;
|
||||
|
||||
// 2. Tính vận tốc command từ cycle trước
|
||||
double vCmdPrev = (vLeftCmdPrev + vRightCmdPrev) / 2.0;
|
||||
double omegaCmdPrev = (vRightCmdPrev - vLeftCmdPrev) / wheelbase;
|
||||
|
||||
// 3. Tính prediction horizon
|
||||
double predictionHorizon = CalculatePredictionHorizon(vActual);
|
||||
|
||||
// 4. Predict velocity cho từng bánh
|
||||
double vLeftPredicted = _motorDynamicsModel.PredictVelocity(
|
||||
vLeftCmdPrev,
|
||||
vLeftActual,
|
||||
predictionHorizon
|
||||
);
|
||||
|
||||
double vRightPredicted = _motorDynamicsModel.PredictVelocity(
|
||||
vRightCmdPrev,
|
||||
vRightActual,
|
||||
predictionHorizon
|
||||
);
|
||||
|
||||
// 5. Tính linear & angular predicted
|
||||
double vPredicted = (vLeftPredicted + vRightPredicted) / 2.0;
|
||||
double omegaPredicted = (vRightPredicted - vLeftPredicted) / wheelbase;
|
||||
|
||||
// 6. Update model confidence
|
||||
UpdateModelConfidence(vPredicted, vActual);
|
||||
|
||||
// 7. Tính tracking error
|
||||
double linearErr = CalculateLinearTrackingError(vCmdPrev, vActual);
|
||||
double angularErr = CalculateAngularTrackingError(omegaCmdPrev, omegaActual);
|
||||
double combinedTrackingError = 0.65f * linearErr + 0.35f * angularErr;
|
||||
|
||||
// 8. Calculate adaptive blend ratio
|
||||
double blendRatio = CalculateAdaptiveBlendRatio(
|
||||
combinedTrackingError,
|
||||
_currentConfidence
|
||||
);
|
||||
|
||||
// 9. Blend predicted và actual
|
||||
var vHybrid = (blendRatio * vPredicted) + ((1.0 - blendRatio) * vActual);
|
||||
double omegaHybrid = blendRatio * omegaPredicted + (1.0 - blendRatio) * omegaActual;
|
||||
|
||||
// 10. Return result
|
||||
return (vHybrid, omegaHybrid);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tính prediction horizon dựa vào lookahead distance
|
||||
/// </summary>
|
||||
private double CalculatePredictionHorizon(double vActual)
|
||||
{
|
||||
// Lookahead distance
|
||||
double lookahead = _purePursuitConfig.LookaheadMin + _purePursuitConfig.Kdd * Math.Abs(vActual);
|
||||
|
||||
lookahead = Math.Clamp(lookahead, _purePursuitConfig.LookaheadMin, _purePursuitConfig.LookaheadMax);
|
||||
|
||||
// Prediction time = lookahead / velocity
|
||||
// Nếu vận tốc quá nhỏ, dùng một giá trị minimum
|
||||
double predictionTime = lookahead / Math.Max(Math.Abs(vActual), 0.1);
|
||||
|
||||
// Giới hạn prediction time (không nên quá xa)
|
||||
predictionTime = Math.Clamp(predictionTime, 0.1, 2.0);
|
||||
|
||||
return predictionTime;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tính linear velocity tracking error (normalized)
|
||||
/// </summary>
|
||||
private static double CalculateLinearTrackingError(double vCmd, double vActual)
|
||||
{
|
||||
double error = Math.Abs(vCmd - vActual);
|
||||
double normalizedError = error / Math.Max(Math.Abs(vCmd), 0.1);
|
||||
return normalizedError;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tính angular velocity tracking error (normalized)
|
||||
/// </summary>
|
||||
private static double CalculateAngularTrackingError(double oCmd, double oActual)
|
||||
{
|
||||
double error = Math.Abs(oCmd - oActual);
|
||||
double normalizedError = error / Math.Max(Math.Abs(oCmd), 0.05f);
|
||||
return normalizedError;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Update model confidence dựa trên prediction accuracy
|
||||
/// </summary>
|
||||
private void UpdateModelConfidence(double vPredictedPrev, double vActualNow)
|
||||
{
|
||||
// Prediction error từ cycle trước
|
||||
double predError = Math.Abs(vPredictedPrev - vActualNow) / Math.Max(Math.Abs(vActualNow), 0.1);
|
||||
|
||||
_predictionErrors.Add(predError);
|
||||
|
||||
// Tính confidence dựa trên average error
|
||||
if (_predictionErrors.Count > 0)
|
||||
{
|
||||
double avgError = _predictionErrors.Average();
|
||||
|
||||
// Confidence = 1 - avgError (capped)
|
||||
double newConfidence = Math.Clamp(1.0 - avgError, 0.0, 1.0);
|
||||
|
||||
// Smooth update với decay
|
||||
_currentConfidence = _estimatorConfig.ConfidenceDecayRate * _currentConfidence + (1.0 - _estimatorConfig.ConfidenceDecayRate) * newConfidence;
|
||||
|
||||
_currentConfidence = Math.Max(_currentConfidence, _estimatorConfig.MinConfidence);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Calculate adaptive blend ratio
|
||||
/// </summary>
|
||||
private double CalculateAdaptiveBlendRatio(
|
||||
double trackingError,
|
||||
double modelConfidence)
|
||||
{
|
||||
double alpha;
|
||||
|
||||
// Factor 1: Tracking error
|
||||
if (trackingError < _estimatorConfig.GoodTrackingThreshold)
|
||||
{
|
||||
// Motor tracking tốt → tin prediction nhiều
|
||||
alpha = _estimatorConfig.GoodTrackingBlend;
|
||||
}
|
||||
else if (trackingError < _estimatorConfig.ModerateTrackingThreshold)
|
||||
{
|
||||
// Moderate error → balanced
|
||||
alpha = _estimatorConfig.ModerateTrackingBlend;
|
||||
}
|
||||
else
|
||||
{
|
||||
// Poor tracking (slip/overload) → tin actual nhiều
|
||||
alpha = _estimatorConfig.PoorTrackingBlend;
|
||||
}
|
||||
|
||||
// Factor 2: Model confidence
|
||||
// Nếu model không chính xác, giảm blend ratio
|
||||
alpha *= modelConfidence;
|
||||
|
||||
// Clamp trong khoảng cho phép
|
||||
alpha = Math.Clamp(alpha, _estimatorConfig.MinBlendRatio, _estimatorConfig.MaxBlendRatio);
|
||||
|
||||
return alpha;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reset estimator state
|
||||
/// </summary>
|
||||
public void Reset()
|
||||
{
|
||||
_predictionErrors.Clear();
|
||||
_currentConfidence = 1.0;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get current model confidence
|
||||
/// </summary>
|
||||
public double GetModelConfidence()
|
||||
{
|
||||
return _currentConfidence;
|
||||
}
|
||||
|
||||
public void LoadConfig()
|
||||
{
|
||||
_motorDynamicsConifg = NavigationConfig.GetMotorDynamicsConfig();
|
||||
_motorDynamicsModel = new(_motorDynamicsConifg);
|
||||
_estimatorConfig = NavigationConfig.GetVelocityEstimatorConfig();
|
||||
_purePursuitConfig = NavigationConfig.GetPurepursuitConfig();
|
||||
_signalConfig = NavigationConfig.GetVelocitySignalProcessingConfig();
|
||||
}
|
||||
|
||||
public void SetAcceleration(double acc)
|
||||
{
|
||||
// SetAcceleration not available - commented out
|
||||
// InverseKinematic.SetAcceleration(acc);
|
||||
}
|
||||
|
||||
public void SetDeceleration(double dec)
|
||||
{
|
||||
// SetDeceleration not available - commented out
|
||||
// InverseKinematic.SetDeceleration(dec);
|
||||
}
|
||||
|
||||
public bool EnsureInverseKinematicsReady(CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
// Increment counter for logging throttling
|
||||
_ensureIKReadyCounter++;
|
||||
|
||||
// Check if need to reset fault first
|
||||
// Note: DifferentialDrive doesn't expose IsFaulted, so we try FaultReset if not enabled
|
||||
if (!InverseKinematic.IsOperationEnabled)
|
||||
{
|
||||
// Try fault reset first (in case it's in fault state)
|
||||
InverseKinematic.FaultReset();
|
||||
PreciseDelay(200, cancellationToken);
|
||||
}
|
||||
|
||||
// Check if IInverseKinematics is in OperationEnabled state
|
||||
if (!InverseKinematic.IsOperationEnabled)
|
||||
{
|
||||
// Auto-enable IInverseKinematics through state transitions
|
||||
// Enable() is a convenience method that automatically transitions through all states
|
||||
int maxAttempts = 3;
|
||||
int attemptDelay = 300; // ms
|
||||
|
||||
for (int i = 0; i < maxAttempts && !InverseKinematic.IsOperationEnabled; i++)
|
||||
{
|
||||
InverseKinematic.Enable();
|
||||
PreciseDelay(attemptDelay, cancellationToken);
|
||||
}
|
||||
|
||||
// Check if enabled successfully
|
||||
if (!InverseKinematic.IsOperationEnabled)
|
||||
{
|
||||
// Log only once every 10 times to avoid spam
|
||||
if (_ensureIKReadyCounter % 10 == 0)
|
||||
{
|
||||
_logger.LogWarning("IInverseKinematics is not in OperationEnabled state. Cannot send velocity.");
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Check and set operation mode to ProfileVelocity (synchronous)
|
||||
try
|
||||
{
|
||||
// GetOperationMode/SetOperationMode not available - commented out
|
||||
// OperationMode currentMode = InverseKinematic.GetOperationMode();
|
||||
// if (currentMode != OperationMode.ProfileVelocity)
|
||||
// {
|
||||
// InverseKinematic.SetOperationMode(OperationMode.ProfileVelocity);
|
||||
// }
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Log only once every 10 times to avoid spam
|
||||
if (_ensureIKReadyCounter % 10 == 0)
|
||||
{
|
||||
_logger.LogWarning(ex, "Error checking/setting operation mode");
|
||||
}
|
||||
// Continue anyway to prevent blocking
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Log only once every 10 times to avoid spam
|
||||
if (_ensureIKReadyCounter % 10 == 0)
|
||||
{
|
||||
_logger.LogError(ex, "Error ensuring IInverseKinematics ready");
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Precise synchronous delay using Thread.Sleep for longer delays and SpinWait for short delays
|
||||
/// This ensures accurate timing for the update loop without async overhead
|
||||
/// </summary>
|
||||
private static void PreciseDelay(int milliseconds, CancellationToken cancellationToken)
|
||||
{
|
||||
if (milliseconds <= 0)
|
||||
return;
|
||||
|
||||
if (milliseconds > 1)
|
||||
{
|
||||
// Use Thread.Sleep for longer delays (synchronous, more precise in dedicated thread)
|
||||
// Check cancellation periodically during sleep
|
||||
var sleepStart = DateTime.UtcNow;
|
||||
while ((DateTime.UtcNow - sleepStart).TotalMilliseconds < milliseconds)
|
||||
{
|
||||
if (cancellationToken.IsCancellationRequested)
|
||||
return;
|
||||
|
||||
var remaining = milliseconds - (int)(DateTime.UtcNow - sleepStart).TotalMilliseconds;
|
||||
if (remaining > 0)
|
||||
{
|
||||
Thread.Sleep(Math.Min(remaining, 10)); // Sleep in 10ms chunks to check cancellation
|
||||
}
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
// Use SpinWait for very short delays to maintain precise timing
|
||||
var spinWait = new SpinWait();
|
||||
for (int i = 0; i < 10; i++)
|
||||
{
|
||||
if (cancellationToken.IsCancellationRequested)
|
||||
break;
|
||||
spinWait.SpinOnce();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,874 @@
|
||||
using RobotNet.VDA5050.Order;
|
||||
using RobotNet10.Common;
|
||||
using RobotNet10.RobotApp.Detection;
|
||||
using RobotNet10.RobotApp.Interfaces;
|
||||
using RobotNet10.RobotApp.Services.ConfigManager;
|
||||
using RobotNet10.RobotApp.Services.Exceptions;
|
||||
using RobotNet10.RobotApp.Services.Navigation.CSharp;
|
||||
using RobotNet10.RobotApp.Services.Robot.Models;
|
||||
using RobotNet10.RobotApp.Services.Simulation;
|
||||
using RobotNet10.RobotApp.Shared.Enums;
|
||||
using RobotNet10.Shared.Geometry;
|
||||
|
||||
namespace RobotNet10.RobotApp.Services.Navigation;
|
||||
|
||||
public class NavigationConfig
|
||||
{
|
||||
public double MaxLinearVelocity { get; set; }
|
||||
public double MaxAngularVelocity { get; set; }
|
||||
public double MinLinearVelocity { get; set; }
|
||||
public double RotateAngularVelocity { get; set; }
|
||||
public double Acceleration { get; set; } = 0.5;
|
||||
public double Deceleration { get; set; } = 0.5;
|
||||
public double ReachedRadius { get; set; } = 0.03;
|
||||
public double HeadingTolerance { get; set; } = 3.0;
|
||||
public double InitialRotationThreshold { get; set; } = 5.0;
|
||||
public double DockToMaxSpeed { get; set; } = 0.3;
|
||||
public double DockToRetrySpeed { get; set; } = 0.05;
|
||||
public double DockToRotateSpeed { get; set; } = 0.05;
|
||||
public Dictionary<SafetySpeed, double> SafetySpeedMap { get; set; } = [];
|
||||
/// <summary>
|
||||
/// Maximum distance (meters) from goal at which a Moving overshoot is still accepted as Completed.
|
||||
/// Default: 0.15m
|
||||
///
|
||||
/// Meaning: When overshoot is detected during Moving, if robot is within this radius of the goal,
|
||||
/// navigation proceeds to final rotation → Completed. Otherwise → Error.
|
||||
///
|
||||
/// ↑ Increase (0.2-0.3):
|
||||
/// ✓ More tolerant of overshoot — fewer Error states
|
||||
/// ✗ Robot may report Completed at a position far from goal
|
||||
///
|
||||
/// ↓ Decrease (0.05-0.1):
|
||||
/// ✓ Higher positional accuracy requirement
|
||||
/// ✗ More likely to trigger Error on minor overshoot
|
||||
///
|
||||
/// Tuning Tips:
|
||||
/// - Should be ≥ ReachedRadius
|
||||
/// - Must be ≤ MovingOvershootDetectionRadius to be meaningful
|
||||
/// - For high-precision tasks: 0.05-0.1
|
||||
/// - For general navigation: 0.15-0.2
|
||||
/// </summary>
|
||||
public double OvershootAcceptanceRadius { get; set; } = 0.15;
|
||||
|
||||
/// <summary>
|
||||
/// Distance (meters) from goal at which overshoot detection begins during Moving.
|
||||
/// Default: 0.5m
|
||||
///
|
||||
/// Meaning: Overshoot detection only activates when robot is within this radius of the final goal.
|
||||
/// Outside this radius, distance fluctuations are ignored.
|
||||
///
|
||||
/// ↑ Increase (0.8-1.0):
|
||||
/// ✓ Earlier overshoot detection
|
||||
/// ✗ May false-trigger on path curvature near goal
|
||||
///
|
||||
/// ↓ Decrease (0.2-0.3):
|
||||
/// ✓ Fewer false triggers
|
||||
/// ✗ Late detection — robot may travel further past goal before stopping
|
||||
///
|
||||
/// Tuning Tips:
|
||||
/// - Should be > OvershootAcceptanceRadius
|
||||
/// - Typical: 2-5x the ReachedRadius
|
||||
/// - If robot has high inertia/speed: increase to 0.8-1.0
|
||||
/// </summary>
|
||||
public double MovingOvershootDetectionRadius { get; set; } = 0.5;
|
||||
|
||||
/// <summary>
|
||||
/// Distance (meters) from checkpoint at which PID deceleration begins during Moving.
|
||||
/// Default: 5.0m
|
||||
///
|
||||
/// Meaning: When distance to checkpoint > this value, robot runs at MaxLinearVelocity.
|
||||
/// Below this distance, PID ramps velocity down proportionally.
|
||||
///
|
||||
/// ↑ Increase (7-10):
|
||||
/// ✓ Earlier, smoother deceleration
|
||||
/// ✗ Slower average speed on long paths
|
||||
///
|
||||
/// ↓ Decrease (2-3):
|
||||
/// ✓ Faster average speed — stays at max longer
|
||||
/// ✗ Sharper deceleration, may overshoot on heavy robots
|
||||
///
|
||||
/// Tuning Tips:
|
||||
/// - Depends on MaxLinearVelocity and robot mass/inertia
|
||||
/// - Rule of thumb: stopping distance ≈ v² / (2 × deceleration)
|
||||
/// - Heavy/fast robot: 7-10m; Light/slow robot: 2-3m
|
||||
/// </summary>
|
||||
public double DecelerationDistance { get; set; } = 5.0;
|
||||
|
||||
/// <summary>
|
||||
/// Maximum linear velocity (m/s) when robot is carrying a load during Moving.
|
||||
/// Default: 0.3 m/s
|
||||
///
|
||||
/// When hasLoad=true, MaxLinearVelocity is capped at min(MaxLinearVelocity, LoadedMaxLinearVelocity).
|
||||
/// </summary>
|
||||
public double LoadedMaxLinearVelocity { get; set; } = 0.3;
|
||||
|
||||
/// <summary>
|
||||
/// Maximum heading error (degrees) allowed when starting MoveStraight or Docking with a load.
|
||||
/// Default: 10.0 degrees
|
||||
///
|
||||
/// When hasLoad=true, the robot cannot rotate to correct heading before MoveStraight/Docking.
|
||||
/// If the heading error exceeds this threshold at start, navigation transitions to Error.
|
||||
/// </summary>
|
||||
public double LoadedHeadingErrorThresholdDegrees { get; set; } = 10.0;
|
||||
}
|
||||
|
||||
public partial class CSharpNavigation : INavigation, IDisposable
|
||||
{
|
||||
public bool IsReady { get; private set; }
|
||||
public bool Driving => NavState is NavigationState.Rotating or NavigationState.Moving or NavigationState.Docking or NavigationState.FinePositioning or NavigationState.MovingStraight or NavigationState.SafetyStop;
|
||||
public double VelocityX => VelController.ActualVelocity.Linear;
|
||||
public double VelocityY { get; private set; }
|
||||
public double Omega => VelController.ActualVelocity.Angular;
|
||||
public NavigationState State => NavState;
|
||||
public IReadOnlyList<NavigationNode>? CurrentWaypoints => MovePurePursuit?.Waypoints_Value ?? MoveStraightController?.Waypoints_Value ?? DockToController?.Waypoints_Value;
|
||||
|
||||
// DockTo monitoring properties (read-only, for NavigationMonitor)
|
||||
public bool IsDockingActive => NavState is NavigationState.Docking or NavigationState.FinePositioning;
|
||||
public NavigationNode? DockGoal => DockToController?.Goal;
|
||||
public string DockPhase => NavState switch
|
||||
{
|
||||
NavigationState.Docking => "Approaching",
|
||||
NavigationState.FinePositioning when _finePositioningIsAligning => "Aligning",
|
||||
NavigationState.FinePositioning => "Advancing",
|
||||
_ => ""
|
||||
};
|
||||
public string DockDirection => DockToController?.DockConfig?.DockToDirection.ToString() ?? "";
|
||||
public int DockRetryCount => _finePositioningRetryCount;
|
||||
public int DockMaxRetries => _finePositioningMaxRetries;
|
||||
public int DockWaypointCount => DockToController?.Waypoints_Value?.Count ?? 0;
|
||||
public NavigationNode? DockStartNode => DockToController?.StartNode;
|
||||
public IReadOnlyList<NavigationNode>? DockWaypoints => DockToController?.Waypoints_Value;
|
||||
|
||||
public event Action<NavigationState>? OnNavigationFinished;
|
||||
|
||||
private readonly ILocalization Localization;
|
||||
public readonly IVelocityController VelController;
|
||||
private readonly INavigationConfig NavigationConfig;
|
||||
private readonly ILogger<CSharpNavigation> Logger;
|
||||
|
||||
private NavigationState NavState = NavigationState.Idle;
|
||||
private NavigationState ResumeState = NavigationState.Idle;
|
||||
|
||||
// Safety stop: saves previous state for Refresh() to resume from
|
||||
private NavigationState _safetyStopPreviousState = NavigationState.Idle;
|
||||
|
||||
private WatchThread<CSharpNavigation>? NavThread = null;
|
||||
private const int CycleHandlerMilliseconds = 30;
|
||||
|
||||
private PID? MovePID;
|
||||
private PurePursuit? MovePurePursuit;
|
||||
private DockToController? DockToController;
|
||||
private DockToController? MoveStraightController;
|
||||
private IDetectSession? _dockSession;
|
||||
|
||||
private OrderNode? GoalRotate;
|
||||
private OrderNode? CurrentBaseNode;
|
||||
private HashSet<string> ProcessedRotations = [];
|
||||
|
||||
private double TargetAngle = 0;
|
||||
private PID? RotatePID;
|
||||
|
||||
private readonly NavigationConfig NavCog;
|
||||
private double MaxLinearVelocity = 0;
|
||||
|
||||
// Overshoot detection
|
||||
private double _oldDistanceToGoal = double.MaxValue;
|
||||
private bool _wasApproaching = false;
|
||||
|
||||
// Initial rotation skip
|
||||
private bool _isInitialRotation = false;
|
||||
|
||||
// Fine Positioning state
|
||||
private int _finePositioningRetryCount = 0;
|
||||
private int _finePositioningMaxRetries = 3;
|
||||
private int _finePositioningCycleCount = 0;
|
||||
private int _finePositioningTimeoutMs = 6000;
|
||||
private bool _finePositioningIsAligning = true;
|
||||
private PID? _finePositionRotatePID = null;
|
||||
private RobotDirection _finePositioningDirection = RobotDirection.FORWARD;
|
||||
private bool _fpWasApproaching = false;
|
||||
private int _fpOvershootCounter = 0;
|
||||
|
||||
private int _disposed = 0;
|
||||
|
||||
// HasLoad: robot is carrying a load — affects speed limits, rotation, and FinePositioning
|
||||
private bool _hasLoad = false;
|
||||
|
||||
// Tracks whether velocity (0,0) has already been sent when entering Paused/SafetyStop.
|
||||
// Prevents Navigation from continuously overwriting ManualControl velocity commands.
|
||||
private bool _idleVelocityZeroSent = false;
|
||||
|
||||
private void ResetOvershootState()
|
||||
{
|
||||
_oldDistanceToGoal = double.MaxValue;
|
||||
_wasApproaching = false;
|
||||
_overshootCounter = 0;
|
||||
}
|
||||
|
||||
public CSharpNavigation(IServiceProvider ServiceProvider)
|
||||
{
|
||||
Localization = ServiceProvider.GetRequiredService<ILocalization>();
|
||||
VelController = ServiceProvider.GetRequiredService<IVelocityController>();
|
||||
NavigationConfig = ServiceProvider.GetRequiredService<INavigationConfig>();
|
||||
Logger = ServiceProvider.GetRequiredService<ILogger<CSharpNavigation>>();
|
||||
NavCog = NavigationConfig.GetNavigationConfig();
|
||||
MaxLinearVelocity = NavCog.MaxLinearVelocity;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (Interlocked.Exchange(ref _disposed, 1) != 0) return;
|
||||
HandleNavigationStop();
|
||||
Clear();
|
||||
OnNavigationFinished?.Invoke(NavState);
|
||||
GC.SuppressFinalize(this);
|
||||
}
|
||||
|
||||
public void Start()
|
||||
{
|
||||
IsReady = true;
|
||||
}
|
||||
|
||||
public void Stop()
|
||||
{
|
||||
Dispose();
|
||||
}
|
||||
|
||||
protected void HandleNavigationStart()
|
||||
{
|
||||
NavThread = new(CycleHandlerMilliseconds, NavigationHandler, Logger);
|
||||
NavThread.Start();
|
||||
}
|
||||
|
||||
protected void HandleNavigationStop()
|
||||
{
|
||||
NavThread?.Dispose();
|
||||
NavThread = null;
|
||||
}
|
||||
|
||||
public void CancelMovement()
|
||||
{
|
||||
NavState = NavigationState.Canceled;
|
||||
Dispose();
|
||||
}
|
||||
|
||||
public void Move(RobotNet.VDA5050.Order.OrderMsg order, bool hasLoad = false)
|
||||
{
|
||||
var nodes = order.Nodes;
|
||||
var edges = order.Edges;
|
||||
if (NavThread is not null) throw new NavigationException("The Navigation module is called during operation.");
|
||||
|
||||
_hasLoad = hasLoad;
|
||||
NavState = NavigationState.Initializing;
|
||||
if (!VelController.EnsureInverseKinematicsReady(CancellationToken.None))
|
||||
{
|
||||
NavState = NavigationState.Idle;
|
||||
throw new NavigationException("The Velocity Controller is not ready for inverse kinematics.");
|
||||
}
|
||||
VelController.LoadConfig();
|
||||
VelController.SetAcceleration(NavCog.Acceleration);
|
||||
VelController.SetDeceleration(NavCog.Deceleration);
|
||||
MovePID = new PID(NavigationConfig.GetMovePidConfig());
|
||||
var ppConfig = NavigationConfig.GetPurepursuitConfig();
|
||||
MovePurePursuit = new PurePursuit(NavigationConfig.GetPurepursuitConfig(), NavigationConfig.GetStanleyConig()).WithPath(nodes, edges, Localization.Theta);
|
||||
|
||||
// Reset overshoot detection state
|
||||
ResetOvershootState();
|
||||
_isInitialRotation = true;
|
||||
|
||||
(_, int index) = MovePurePursuit.OnNode(Localization.X, Localization.Y);
|
||||
if (index >= MovePurePursuit.Waypoints_Value.Count - 1)
|
||||
{
|
||||
NavState = NavigationState.Completed;
|
||||
Dispose();
|
||||
return;
|
||||
}
|
||||
|
||||
// === Local Planner: sinh approach path ===
|
||||
var lpConfig = NavigationConfig.GetLocalPlannerConfig();
|
||||
double heading;
|
||||
|
||||
var approachResult = lpConfig.Enabled
|
||||
? MovePurePursuit.GenerateAndPrependApproachPath(
|
||||
Localization.X, Localization.Y, index, lpConfig)
|
||||
: ApproachResult.Disabled;
|
||||
|
||||
if (approachResult == ApproachResult.ApproachGenerated)
|
||||
{
|
||||
// Heading = hướng tiếp tuyến đầu approach curve (P0 → P1)
|
||||
var wp0 = MovePurePursuit.Waypoints_Value[0];
|
||||
var wp1 = MovePurePursuit.Waypoints_Value[1];
|
||||
heading = Math.Atan2(wp1.Y - wp0.Y, wp1.X - wp0.X);
|
||||
|
||||
if (wp0.Direction == RobotDirection.BACKWARD)
|
||||
heading += Math.PI;
|
||||
}
|
||||
else if (approachResult == ApproachResult.AlreadyOnPath)
|
||||
{
|
||||
// Robot đã trên path → dùng path tangent tại closest waypoint
|
||||
var wp = MovePurePursuit.Waypoints_Value[index];
|
||||
var wpNext = MovePurePursuit.Waypoints_Value[index + 1];
|
||||
heading = Math.Atan2(wpNext.Y - wp.Y, wpNext.X - wp.X);
|
||||
|
||||
if (wp.Direction == RobotDirection.BACKWARD)
|
||||
heading += Math.PI;
|
||||
}
|
||||
else
|
||||
{
|
||||
// TooFarFromPath / Disabled: heading hướng về lookahead point
|
||||
double lookahead = (ppConfig.LookaheadMin + ppConfig.LookaheadMax) / 2;
|
||||
var targetPoint = FindLookaheadTarget(index, lookahead);
|
||||
targetPoint ??= MovePurePursuit.Waypoints_Value[^1];
|
||||
|
||||
heading = Math.Atan2(targetPoint.Y - Localization.Y, targetPoint.X - Localization.X);
|
||||
if (targetPoint.Direction == RobotDirection.BACKWARD)
|
||||
heading += Math.PI;
|
||||
}
|
||||
|
||||
heading = SpaceCompute.NormalizeRadianAngle(heading);
|
||||
|
||||
// Cap speed when loaded
|
||||
if (_hasLoad)
|
||||
{
|
||||
MaxLinearVelocity = Math.Min(MaxLinearVelocity, NavCog.LoadedMaxLinearVelocity);
|
||||
}
|
||||
|
||||
Rotate(heading);
|
||||
}
|
||||
|
||||
public void MoveStraight(double x, double y, bool hasLoad = false, RobotDirection? direction = null)
|
||||
{
|
||||
if (NavThread is not null) throw new NavigationException("The Navigation module is called during operation.");
|
||||
|
||||
_hasLoad = hasLoad;
|
||||
NavState = NavigationState.Initializing;
|
||||
if (!VelController.EnsureInverseKinematicsReady(CancellationToken.None))
|
||||
{
|
||||
NavState = NavigationState.Idle;
|
||||
throw new NavigationException("The Velocity Controller is not ready for inverse kinematics.");
|
||||
}
|
||||
VelController.LoadConfig();
|
||||
VelController.SetAcceleration(NavCog.Acceleration);
|
||||
VelController.SetDeceleration(NavCog.Deceleration);
|
||||
|
||||
MovePID = new PID(NavigationConfig.GetMovePidConfig());
|
||||
var straightCfg = NavigationConfig.GetMoveStraightConfig().Clone();
|
||||
if (direction.HasValue) straightCfg.DockToDirection = direction.Value;
|
||||
|
||||
var startNode = new NavigationNode
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
X = Localization.X,
|
||||
Y = Localization.Y,
|
||||
};
|
||||
|
||||
var goalNode = new NavigationNode
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
X = x,
|
||||
Y = y,
|
||||
};
|
||||
|
||||
MoveStraightController = new DockToController(straightCfg).WithPath(startNode, goalNode);
|
||||
|
||||
(_, int index) = MoveStraightController.GetClosestAheadWaypoint(Localization.X, Localization.Y);
|
||||
if (index >= MoveStraightController.Waypoints_Value.Count - 1)
|
||||
{
|
||||
NavState = NavigationState.Completed;
|
||||
Dispose();
|
||||
return;
|
||||
}
|
||||
|
||||
double heading = Math.Atan2(y - Localization.Y, x - Localization.X);
|
||||
if (straightCfg.DockToDirection == RobotDirection.BACKWARD)
|
||||
heading += Math.PI;
|
||||
heading = SpaceCompute.NormalizeRadianAngle(heading);
|
||||
|
||||
ResetOvershootState();
|
||||
MaxLinearVelocity = NavCog.MaxLinearVelocity;
|
||||
|
||||
if (_hasLoad)
|
||||
{
|
||||
// When loaded: no rotation allowed. Check heading error.
|
||||
double headingError = heading - Localization.Theta;
|
||||
if (headingError > Math.PI) headingError -= 2 * Math.PI;
|
||||
else if (headingError < -Math.PI) headingError += 2 * Math.PI;
|
||||
|
||||
double thresholdRad = NavCog.LoadedHeadingErrorThresholdDegrees * Math.PI / 180.0;
|
||||
if (Math.Abs(headingError) > thresholdRad)
|
||||
{
|
||||
Logger.LogError($"MoveStraight hasLoad: heading error {Math.Abs(headingError) * 180 / Math.PI:F1}° exceeds threshold {NavCog.LoadedHeadingErrorThresholdDegrees:F1}°");
|
||||
NavState = NavigationState.Error;
|
||||
Dispose();
|
||||
return;
|
||||
}
|
||||
|
||||
// Skip rotation, go directly to MovingStraight
|
||||
NavState = NavigationState.MovingStraight;
|
||||
HandleNavigationStart();
|
||||
}
|
||||
else
|
||||
{
|
||||
_isInitialRotation = true;
|
||||
Rotate(heading);
|
||||
}
|
||||
}
|
||||
|
||||
public void DockTo(IDetectSession session, bool hasLoad = false, RobotDirection? direction = null)
|
||||
{
|
||||
if (NavThread is not null) throw new NavigationException("The Navigation module is called during operation.");
|
||||
var goal = session.Goal ?? throw new NavigationException("Dock to Goal is not existed");
|
||||
_hasLoad = hasLoad;
|
||||
_dockSession = session;
|
||||
NavState = NavigationState.Initializing;
|
||||
|
||||
if (!VelController.EnsureInverseKinematicsReady(CancellationToken.None))
|
||||
{
|
||||
NavState = NavigationState.Idle;
|
||||
throw new NavigationException("The Velocity Controller is not ready for inverse kinematics.");
|
||||
}
|
||||
VelController.LoadConfig();
|
||||
VelController.SetAcceleration(NavCog.Acceleration);
|
||||
VelController.SetDeceleration(NavCog.Deceleration);
|
||||
|
||||
|
||||
MovePID = new PID(NavigationConfig.GetMovePidConfig());
|
||||
var docktoConfig = NavigationConfig.GetDockToConfig().Clone();
|
||||
if (direction.HasValue) docktoConfig.DockToDirection = direction.Value;
|
||||
var currentGoal = new NavigationNode()
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
X = goal.Pose.Position.X,
|
||||
Y = goal.Pose.Position.Y,
|
||||
Speed = NavCog.DockToMaxSpeed,
|
||||
Theta = goal.Pose.Orientation.ToYawRadian(),
|
||||
};
|
||||
|
||||
var startNode = GetDockToStartNode(Localization.X, Localization.Y, currentGoal.X, currentGoal.Y, currentGoal.Theta ?? 0, docktoConfig.DockToLength);
|
||||
DockToController = new DockToController(docktoConfig).WithPath(startNode, currentGoal);
|
||||
|
||||
(_, int index) = DockToController.GetClosestAheadWaypoint(Localization.X, Localization.Y);
|
||||
if (index >= DockToController.Waypoints_Value.Count - 1)
|
||||
{
|
||||
NavState = NavigationState.Completed;
|
||||
|
||||
Dispose();
|
||||
return;
|
||||
}
|
||||
|
||||
var pathAngle = Math.Atan2(currentGoal.Y - startNode.Y, currentGoal.X - startNode.X);
|
||||
|
||||
double frontX = DockToController.Waypoints_Value[index].X + docktoConfig.WheelBase * Math.Cos(pathAngle);
|
||||
double frontY = DockToController.Waypoints_Value[index].Y + docktoConfig.WheelBase * Math.Sin(pathAngle);
|
||||
|
||||
double dx = frontX - Localization.X;
|
||||
double dy = frontY - Localization.Y;
|
||||
double heading = Math.Atan2(dy, dx);
|
||||
|
||||
if (docktoConfig.DockToDirection == RobotDirection.BACKWARD)
|
||||
heading += Math.PI;
|
||||
|
||||
heading = SpaceCompute.NormalizeRadianAngle(heading);
|
||||
|
||||
ResetOvershootState();
|
||||
MaxLinearVelocity = NavCog.DockToMaxSpeed;
|
||||
|
||||
// Load Fine Positioning config from DockToConfig
|
||||
_finePositioningTimeoutMs = docktoConfig.FinePositioningTimeoutMs;
|
||||
_finePositioningMaxRetries = docktoConfig.FinePositioningMaxRetries;
|
||||
|
||||
// Reset Fine Positioning state
|
||||
_finePositioningRetryCount = 0;
|
||||
_finePositioningCycleCount = 0;
|
||||
_finePositioningIsAligning = true;
|
||||
_finePositionRotatePID = null;
|
||||
_finePositioningDirection = RobotDirection.FORWARD;
|
||||
_fpWasApproaching = false;
|
||||
_fpOvershootCounter = 0;
|
||||
|
||||
if (_hasLoad)
|
||||
{
|
||||
// When loaded: no rotation allowed. Check heading error.
|
||||
double headingError = heading - Localization.Theta;
|
||||
if (headingError > Math.PI) headingError -= 2 * Math.PI;
|
||||
else if (headingError < -Math.PI) headingError += 2 * Math.PI;
|
||||
|
||||
double thresholdRad = NavCog.LoadedHeadingErrorThresholdDegrees * Math.PI / 180.0;
|
||||
if (Math.Abs(headingError) > thresholdRad)
|
||||
{
|
||||
Logger.LogError($"DockTo hasLoad: heading error {Math.Abs(headingError) * 180 / Math.PI:F1}° exceeds threshold {NavCog.LoadedHeadingErrorThresholdDegrees:F1}°");
|
||||
NavState = NavigationState.Error;
|
||||
Dispose();
|
||||
return;
|
||||
}
|
||||
|
||||
// Skip rotation, go directly to Docking
|
||||
NavState = NavigationState.Docking;
|
||||
HandleNavigationStart();
|
||||
}
|
||||
else
|
||||
{
|
||||
_isInitialRotation = true;
|
||||
Rotate(heading);
|
||||
}
|
||||
}
|
||||
|
||||
public void Pause()
|
||||
{
|
||||
ResumeState = NavState;
|
||||
_idleVelocityZeroSent = false;
|
||||
NavState = NavigationState.Paused;
|
||||
}
|
||||
|
||||
public void SafetyStop()
|
||||
{
|
||||
_safetyStopPreviousState = NavState;
|
||||
_idleVelocityZeroSent = false;
|
||||
NavState = NavigationState.SafetyStop;
|
||||
}
|
||||
|
||||
public void Refresh()
|
||||
{
|
||||
_idleVelocityZeroSent = false;
|
||||
if (NavState != NavigationState.SafetyStop)
|
||||
{
|
||||
NavState = NavigationState.Idle;
|
||||
return;
|
||||
}
|
||||
|
||||
var prevState = _safetyStopPreviousState;
|
||||
var x = Localization.X;
|
||||
var y = Localization.Y;
|
||||
|
||||
if (prevState is NavigationState.Moving
|
||||
&& MovePurePursuit?.Waypoints_Value is { Count: > 2 })
|
||||
{
|
||||
MovePurePursuit.ResetTracking();
|
||||
MovePurePursuit.RebuildPath();
|
||||
|
||||
(_, int index) = MovePurePursuit.OnNode(x, y);
|
||||
if (index >= MovePurePursuit.Waypoints_Value.Count - 1)
|
||||
{ NavState = NavigationState.Completed; Dispose(); return; }
|
||||
|
||||
MovePID = new PID(NavigationConfig.GetMovePidConfig());
|
||||
ResetOvershootState();
|
||||
|
||||
// === Local Planner (same logic as Move) ===
|
||||
var lpConfig = NavigationConfig.GetLocalPlannerConfig();
|
||||
var ppConfig = NavigationConfig.GetPurepursuitConfig();
|
||||
double heading;
|
||||
|
||||
var approachResult = lpConfig.Enabled
|
||||
? MovePurePursuit.GenerateAndPrependApproachPath(
|
||||
x, y, index, lpConfig)
|
||||
: ApproachResult.Disabled;
|
||||
|
||||
if (approachResult == ApproachResult.ApproachGenerated)
|
||||
{
|
||||
var wp0 = MovePurePursuit.Waypoints_Value[0];
|
||||
var wp1 = MovePurePursuit.Waypoints_Value[1];
|
||||
heading = Math.Atan2(wp1.Y - wp0.Y, wp1.X - wp0.X);
|
||||
if (wp0.Direction == RobotDirection.BACKWARD) heading += Math.PI;
|
||||
}
|
||||
else if (approachResult == ApproachResult.AlreadyOnPath)
|
||||
{
|
||||
var wp = MovePurePursuit.Waypoints_Value[index];
|
||||
var wpNext = MovePurePursuit.Waypoints_Value[index + 1];
|
||||
heading = Math.Atan2(wpNext.Y - wp.Y, wpNext.X - wp.X);
|
||||
if (wp.Direction == RobotDirection.BACKWARD) heading += Math.PI;
|
||||
}
|
||||
else
|
||||
{
|
||||
double lookahead = (ppConfig.LookaheadMin + ppConfig.LookaheadMax) / 2;
|
||||
var target = FindLookaheadTarget(index, lookahead)
|
||||
?? MovePurePursuit.Waypoints_Value[^1];
|
||||
heading = Math.Atan2(target.Y - y, target.X - x);
|
||||
if (target.Direction == RobotDirection.BACKWARD) heading += Math.PI;
|
||||
}
|
||||
|
||||
heading = SpaceCompute.NormalizeRadianAngle(heading);
|
||||
|
||||
_isInitialRotation = true;
|
||||
RotatePID = new PID(NavigationConfig.GetRotatePidConfig());
|
||||
TargetAngle = heading;
|
||||
NavState = NavigationState.Rotating;
|
||||
}
|
||||
else if (prevState is NavigationState.Docking
|
||||
&& DockToController?.Waypoints_Value is { Count: > 2 })
|
||||
{
|
||||
DockToController.ResetTracking();
|
||||
(_, int index) = DockToController.GetClosestAheadWaypoint(x, y);
|
||||
if (index >= DockToController.Waypoints_Value.Count - 1)
|
||||
{ NavState = NavigationState.Completed; Dispose(); return; }
|
||||
|
||||
MovePID = new PID(NavigationConfig.GetMovePidConfig());
|
||||
ResetOvershootState();
|
||||
|
||||
var pathAngle = Math.Atan2(
|
||||
DockToController.Goal.Y - DockToController.Waypoints_Value[0].Y,
|
||||
DockToController.Goal.X - DockToController.Waypoints_Value[0].X);
|
||||
double frontX = DockToController.Waypoints_Value[index].X
|
||||
+ DockToController.DockConfig.WheelBase * Math.Cos(pathAngle);
|
||||
double frontY = DockToController.Waypoints_Value[index].Y
|
||||
+ DockToController.DockConfig.WheelBase * Math.Sin(pathAngle);
|
||||
double heading = Math.Atan2(frontY - y, frontX - x);
|
||||
if (DockToController.DockConfig.DockToDirection == RobotDirection.BACKWARD)
|
||||
heading += Math.PI;
|
||||
heading = SpaceCompute.NormalizeRadianAngle(heading);
|
||||
|
||||
if (_hasLoad)
|
||||
{
|
||||
// When loaded: check heading error, resume Docking directly without rotation
|
||||
double headingError = heading - Localization.Theta;
|
||||
if (headingError > Math.PI) headingError -= 2 * Math.PI;
|
||||
else if (headingError < -Math.PI) headingError += 2 * Math.PI;
|
||||
|
||||
double thresholdRad = NavCog.LoadedHeadingErrorThresholdDegrees * Math.PI / 180.0;
|
||||
if (Math.Abs(headingError) > thresholdRad)
|
||||
{
|
||||
Logger.LogError($"Refresh Docking hasLoad: heading error {Math.Abs(headingError) * 180 / Math.PI:F1}° exceeds threshold");
|
||||
NavState = NavigationState.Error;
|
||||
Dispose();
|
||||
return;
|
||||
}
|
||||
NavState = NavigationState.Docking;
|
||||
}
|
||||
else
|
||||
{
|
||||
_isInitialRotation = true;
|
||||
RotatePID = new PID(NavigationConfig.GetRotatePidConfig());
|
||||
TargetAngle = heading;
|
||||
NavState = NavigationState.Rotating;
|
||||
}
|
||||
}
|
||||
else if (prevState is NavigationState.MovingStraight
|
||||
&& MoveStraightController?.Waypoints_Value is { Count: > 2 })
|
||||
{
|
||||
MoveStraightController.ResetTracking();
|
||||
(_, int index) = MoveStraightController.GetClosestAheadWaypoint(x, y);
|
||||
if (index >= MoveStraightController.Waypoints_Value.Count - 1)
|
||||
{ NavState = NavigationState.Completed; Dispose(); return; }
|
||||
|
||||
MovePID = new PID(NavigationConfig.GetMovePidConfig());
|
||||
ResetOvershootState();
|
||||
|
||||
double heading = Math.Atan2(
|
||||
MoveStraightController.Goal.Y - y,
|
||||
MoveStraightController.Goal.X - x);
|
||||
if (MoveStraightController.DockConfig.DockToDirection == RobotDirection.BACKWARD)
|
||||
heading += Math.PI;
|
||||
heading = SpaceCompute.NormalizeRadianAngle(heading);
|
||||
|
||||
if (_hasLoad)
|
||||
{
|
||||
// When loaded: check heading error, resume MovingStraight directly without rotation
|
||||
double headingError = heading - Localization.Theta;
|
||||
if (headingError > Math.PI) headingError -= 2 * Math.PI;
|
||||
else if (headingError < -Math.PI) headingError += 2 * Math.PI;
|
||||
|
||||
double thresholdRad = NavCog.LoadedHeadingErrorThresholdDegrees * Math.PI / 180.0;
|
||||
if (Math.Abs(headingError) > thresholdRad)
|
||||
{
|
||||
Logger.LogError($"Refresh MovingStraight hasLoad: heading error {Math.Abs(headingError) * 180 / Math.PI:F1}° exceeds threshold");
|
||||
NavState = NavigationState.Error;
|
||||
Dispose();
|
||||
return;
|
||||
}
|
||||
NavState = NavigationState.MovingStraight;
|
||||
}
|
||||
else
|
||||
{
|
||||
_isInitialRotation = true;
|
||||
RotatePID = new PID(NavigationConfig.GetRotatePidConfig());
|
||||
TargetAngle = heading;
|
||||
NavState = NavigationState.Rotating;
|
||||
}
|
||||
}
|
||||
else if (prevState is NavigationState.Rotating)
|
||||
{
|
||||
RotatePID = new PID(NavigationConfig.GetRotatePidConfig());
|
||||
NavState = NavigationState.Rotating;
|
||||
}
|
||||
else if (prevState is NavigationState.FinePositioning)
|
||||
{
|
||||
_finePositioningCycleCount = 0;
|
||||
_finePositioningIsAligning = true;
|
||||
_finePositionRotatePID = null;
|
||||
NavState = NavigationState.Docking;
|
||||
}
|
||||
else
|
||||
{
|
||||
NavState = NavigationState.Idle;
|
||||
}
|
||||
}
|
||||
|
||||
public void RefreshOrder(Node[] nodes, Edge[] edges)
|
||||
{
|
||||
}
|
||||
|
||||
public void Resume()
|
||||
{
|
||||
if (ResumeState == NavigationState.FinePositioning)
|
||||
{
|
||||
_finePositioningCycleCount = 0;
|
||||
}
|
||||
_idleVelocityZeroSent = false;
|
||||
NavState = ResumeState;
|
||||
}
|
||||
|
||||
public void Rotate(double angle)
|
||||
{
|
||||
if (NavThread is not null) throw new NavigationException("The Navigation module is called during operation.");
|
||||
|
||||
if (!VelController.EnsureInverseKinematicsReady(CancellationToken.None))
|
||||
{
|
||||
NavState = NavigationState.Idle;
|
||||
throw new NavigationException("The Velocity Controller is not ready for inverse kinematics.");
|
||||
}
|
||||
|
||||
RotatePID = new PID(NavigationConfig.GetRotatePidConfig());
|
||||
TargetAngle = SpaceCompute.NormalizeRadianAngle(angle);
|
||||
NavState = NavigationState.Rotating;
|
||||
HandleNavigationStart();
|
||||
}
|
||||
|
||||
public void SetSpeed(double speed)
|
||||
{
|
||||
MaxLinearVelocity = _hasLoad ? Math.Min(speed, NavCog.LoadedMaxLinearVelocity) : speed;
|
||||
}
|
||||
|
||||
protected void UpdateGoal(string goalId)
|
||||
{
|
||||
MovePurePursuit?.UpdateGoal(goalId);
|
||||
}
|
||||
|
||||
public void UpdateOrder(string newBaseNodeId)
|
||||
{
|
||||
var newBaseNode = MovePurePursuit?.OrderNodes.FirstOrDefault(n => n.NodeId == newBaseNodeId);
|
||||
if (newBaseNode is not null && newBaseNode.NodeId != CurrentBaseNode?.NodeId)
|
||||
{
|
||||
CurrentBaseNode = newBaseNode;
|
||||
var newGoalRotate = FindNextRotateGoal();
|
||||
if (newGoalRotate is not null && newGoalRotate.NodeId != GoalRotate?.NodeId)
|
||||
{
|
||||
GoalRotate = newGoalRotate;
|
||||
UpdateGoal(newGoalRotate.NodeId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void Clear()
|
||||
{
|
||||
VelController.SetVelocity(0, 0);
|
||||
CurrentBaseNode = null;
|
||||
MovePurePursuit = null;
|
||||
MovePID = null;
|
||||
RotatePID = null;
|
||||
GoalRotate = null;
|
||||
DockToController = null;
|
||||
MoveStraightController = null;
|
||||
_dockSession = null;
|
||||
ProcessedRotations = [];
|
||||
ResetOvershootState();
|
||||
_isInitialRotation = false;
|
||||
_finePositioningRetryCount = 0;
|
||||
_finePositioningCycleCount = 0;
|
||||
_finePositioningIsAligning = true;
|
||||
_finePositionRotatePID = null;
|
||||
_finePositioningDirection = RobotDirection.FORWARD;
|
||||
_fpWasApproaching = false;
|
||||
_fpOvershootCounter = 0;
|
||||
_hasLoad = false;
|
||||
_idleVelocityZeroSent = false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tìm target point tại lookahead distance từ vị trí hiện tại trên path.
|
||||
/// Nội suy giữa các waypoint để tạo điểm mượt.
|
||||
/// </summary>
|
||||
private NavigationNode? FindLookaheadTarget(int startIndex, double lookaheadDistance)
|
||||
{
|
||||
if (MovePurePursuit is null || startIndex >= MovePurePursuit.Waypoints_Value.Count - 1)
|
||||
return null;
|
||||
|
||||
double accumulatedDistance = 0;
|
||||
|
||||
for (int i = startIndex; i < MovePurePursuit.Waypoints_Value.Count - 1; i++)
|
||||
{
|
||||
double dx = MovePurePursuit.Waypoints_Value[i + 1].X - MovePurePursuit.Waypoints_Value[i].X;
|
||||
double dy = MovePurePursuit.Waypoints_Value[i + 1].Y - MovePurePursuit.Waypoints_Value[i].Y;
|
||||
double segmentLength = Math.Sqrt(dx * dx + dy * dy);
|
||||
|
||||
if (accumulatedDistance + segmentLength >= lookaheadDistance)
|
||||
{
|
||||
double t = (lookaheadDistance - accumulatedDistance) / segmentLength;
|
||||
return new NavigationNode
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
NodeId = string.Empty,
|
||||
X = MovePurePursuit.Waypoints_Value[i].X + t * dx,
|
||||
Y = MovePurePursuit.Waypoints_Value[i].Y + t * dy,
|
||||
Direction = MovePurePursuit.Waypoints_Value[i].Direction
|
||||
};
|
||||
}
|
||||
|
||||
accumulatedDistance += segmentLength;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tìm node có IsWaitingRotate đầu tiên trong path từ currentNode đến currentGoal
|
||||
/// </summary>
|
||||
protected OrderNode? FindNextRotateGoal()
|
||||
{
|
||||
if (CurrentBaseNode == null || MovePurePursuit is null || MovePurePursuit.OrderNodes.Length == 0) return null;
|
||||
|
||||
int goalIndex = Array.FindIndex(MovePurePursuit.OrderNodes, n => n.NodeId == CurrentBaseNode.NodeId);
|
||||
if (goalIndex == -1) return null;
|
||||
|
||||
int lastNodeIdx = Array.FindIndex(MovePurePursuit.OrderNodes, n => n.NodeId == GoalRotate?.NodeId);
|
||||
lastNodeIdx = lastNodeIdx == -1 ? 0 : lastNodeIdx + 1;
|
||||
|
||||
// Tìm từ node hiện tại đến goal
|
||||
for (int i = lastNodeIdx; i <= goalIndex; i++)
|
||||
{
|
||||
var node = MovePurePursuit.OrderNodes[i];
|
||||
|
||||
// Tìm node có IsWaitingRotate và chưa xử lý
|
||||
if (node.IsWaitRotating && !ProcessedRotations.Contains(node.NodeId))
|
||||
{
|
||||
return node;
|
||||
}
|
||||
}
|
||||
return CurrentBaseNode;
|
||||
}
|
||||
|
||||
private NavigationNode GetDockToStartNode(double x, double y, double goalX, double goalY, double goalTheta, double length)
|
||||
{
|
||||
// Hướng vuông góc với theta
|
||||
double perpAngle = SpaceCompute.NormalizeRadianAngle(goalTheta + Math.PI / 2);
|
||||
|
||||
// Hai endpoint của đoạn thẳng, cách goal ±length theo hướng vuông góc
|
||||
double ep1X = goalX + length * Math.Cos(perpAngle);
|
||||
double ep1Y = goalY + length * Math.Sin(perpAngle);
|
||||
|
||||
double ep2X = goalX - length * Math.Cos(perpAngle);
|
||||
double ep2Y = goalY - length * Math.Sin(perpAngle);
|
||||
|
||||
// Chọn endpoint gần (x, y) nhất
|
||||
double dist1Sq = (ep1X - x) * (ep1X - x) + (ep1Y - y) * (ep1Y - y);
|
||||
double dist2Sq = (ep2X - x) * (ep2X - x) + (ep2Y - y) * (ep2Y - y);
|
||||
|
||||
double startX = dist1Sq <= dist2Sq ? ep1X : ep2X;
|
||||
double startY = dist1Sq <= dist2Sq ? ep1Y : ep2Y;
|
||||
|
||||
return new NavigationNode
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
X = startX,
|
||||
Y = startY,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,693 @@
|
||||
using RobotNet10.Common;
|
||||
using RobotNet10.RobotApp.Interfaces;
|
||||
using RobotNet10.RobotApp.Services.ConfigManager;
|
||||
using RobotNet10.RobotApp.Services.Navigation.CSharp;
|
||||
using RobotNet10.RobotApp.Services.Simulation;
|
||||
using RobotNet10.RobotApp.Shared.Enums;
|
||||
using RobotNet10.Shared.Geometry;
|
||||
|
||||
namespace RobotNet10.RobotApp.Services.Navigation;
|
||||
|
||||
public partial class CSharpNavigation
|
||||
{
|
||||
private bool IsBackToPath = false;
|
||||
private double? BackToAngle;
|
||||
private int _overshootCounter = 0;
|
||||
private readonly int _overshootOut = 5;
|
||||
|
||||
private void Rotating()
|
||||
{
|
||||
if (RotatePID is not null)
|
||||
{
|
||||
double Error = SpaceCompute.NormalizeRadianAngle(TargetAngle - Localization.Theta);
|
||||
|
||||
// Skip initial rotation nếu heading error đủ nhỏ
|
||||
if (_isInitialRotation)
|
||||
{
|
||||
double initialThresholdRad = (NavCog?.InitialRotationThreshold ?? 5.0) * Math.PI / 180.0;
|
||||
if (Math.Abs(Error) < initialThresholdRad)
|
||||
{
|
||||
_isInitialRotation = false;
|
||||
VelController.SetVelocity(0, 0);
|
||||
if (MovePurePursuit is not null && MovePurePursuit.Waypoints_Value is not null && MovePurePursuit.Waypoints_Value.Count > 2)
|
||||
{
|
||||
ResetOvershootState();
|
||||
NavState = NavigationState.Moving;
|
||||
}
|
||||
else if (MoveStraightController is not null && MoveStraightController.Waypoints_Value is not null && MoveStraightController.Waypoints_Value.Count > 2)
|
||||
{
|
||||
ResetOvershootState();
|
||||
NavState = NavigationState.MovingStraight;
|
||||
}
|
||||
else if (DockToController is not null && DockToController.Waypoints_Value is not null && DockToController.Waypoints_Value.Count > 2)
|
||||
{
|
||||
ResetOvershootState();
|
||||
NavState = NavigationState.Docking;
|
||||
}
|
||||
else
|
||||
{
|
||||
NavState = NavigationState.Completed;
|
||||
Logger.LogInformation($"Navigation Reached initial heading: Pose({Localization.X} - {Localization.Y} - {Localization.Theta})");
|
||||
Dispose();
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
double headingToleranceRad = (NavCog?.HeadingTolerance ?? 3.0) * Math.PI / 180.0;
|
||||
if (Math.Abs(Error) < headingToleranceRad)
|
||||
{
|
||||
_isInitialRotation = false;
|
||||
if (IsBackToPath && BackToAngle.HasValue)
|
||||
{
|
||||
TargetAngle = BackToAngle.Value;
|
||||
BackToAngle = null;
|
||||
IsBackToPath = false;
|
||||
}
|
||||
else
|
||||
{
|
||||
ResetOvershootState();
|
||||
if (MovePurePursuit is not null && MovePurePursuit.Waypoints_Value is not null && MovePurePursuit.Waypoints_Value.Count > 2)
|
||||
{
|
||||
NavState = NavigationState.Moving;
|
||||
}
|
||||
else if (MoveStraightController is not null && MoveStraightController.Waypoints_Value is not null && MoveStraightController.Waypoints_Value.Count > 2)
|
||||
{
|
||||
NavState = NavigationState.MovingStraight;
|
||||
}
|
||||
else if (DockToController is not null && DockToController.Waypoints_Value is not null && DockToController.Waypoints_Value.Count > 2)
|
||||
{
|
||||
NavState = NavigationState.Docking;
|
||||
}
|
||||
else
|
||||
{
|
||||
NavState = NavigationState.Completed;
|
||||
Logger.LogInformation($"Navigation Reached heading: Pose({Localization.X} - {Localization.Y} - {Localization.Theta})");
|
||||
Dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
var SpeedCal = RotatePID.PID_step(Error, NavCog?.RotateAngularVelocity ?? 0.1, -(NavCog?.RotateAngularVelocity ?? 0.1), CycleHandlerMilliseconds / 1000.0);
|
||||
VelController.SetVelocity(0, SpeedCal);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void Moving()
|
||||
{
|
||||
if (MovePID is not null && MovePurePursuit is not null && MovePurePursuit.Waypoints_Value is not null && MovePurePursuit?.OrderNodes is not null && MovePurePursuit.OrderNodes.Length > 1 && GoalRotate is not null)
|
||||
{
|
||||
var DistanceToGoal = Math.Sqrt(Math.Pow(Localization.X - MovePurePursuit.OrderNodes[^1].X, 2) + Math.Pow(Localization.Y - MovePurePursuit.OrderNodes[^1].Y, 2));
|
||||
var DistanceToCheckingNode = Math.Sqrt(Math.Pow(Localization.X - GoalRotate.X, 2) + Math.Pow(Localization.Y - GoalRotate.Y, 2));
|
||||
var reachedRadius = NavCog?.ReachedRadius ?? 0.05;
|
||||
var deviation = GoalRotate.NodeId == MovePurePursuit.OrderNodes[^1].NodeId ? reachedRadius : GoalRotate.AllowedDeviationXY ?? 0.1;
|
||||
|
||||
// Overshoot detection: phát hiện robot đi qua goal
|
||||
var overshootDetectionRadius = NavCog?.MovingOvershootDetectionRadius ?? 0.5;
|
||||
if (DistanceToGoal < overshootDetectionRadius)
|
||||
{
|
||||
if (DistanceToGoal < _oldDistanceToGoal)
|
||||
{
|
||||
_wasApproaching = true;
|
||||
_overshootCounter = 0;
|
||||
}
|
||||
else if (_wasApproaching && DistanceToGoal > _oldDistanceToGoal)
|
||||
{
|
||||
_overshootCounter++;
|
||||
if (_wasApproaching && _overshootCounter >= _overshootOut)
|
||||
{
|
||||
_wasApproaching = false;
|
||||
VelController.SetVelocity(0, 0);
|
||||
|
||||
// Kiểm tra vị trí overshoot có chấp nhận được không
|
||||
double acceptanceRadius = NavCog?.OvershootAcceptanceRadius ?? 0.15;
|
||||
if (DistanceToGoal > acceptanceRadius)
|
||||
{
|
||||
Logger.LogError($"Moving overshoot too far: distance={DistanceToGoal:F4}m > acceptance={acceptanceRadius}m");
|
||||
NavState = NavigationState.Error;
|
||||
Dispose();
|
||||
return;
|
||||
}
|
||||
|
||||
Logger.LogWarning($"Overshoot detected at distance {DistanceToGoal:F4}m (within acceptance={acceptanceRadius}m), transitioning to final rotation");
|
||||
if (MovePurePursuit.OrderNodes[^1].Theta is { } overshootTheta)
|
||||
{
|
||||
TargetAngle = overshootTheta;
|
||||
NavState = NavigationState.Rotating;
|
||||
RotatePID?.Reset();
|
||||
MovePurePursuit = null;
|
||||
}
|
||||
else
|
||||
{
|
||||
NavState = NavigationState.Completed;
|
||||
Dispose();
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
else _overshootCounter = 0;
|
||||
}
|
||||
_oldDistanceToGoal = DistanceToGoal;
|
||||
|
||||
if (DistanceToCheckingNode > deviation)
|
||||
{
|
||||
double dt = CycleHandlerMilliseconds / 1000.0;
|
||||
double decelerationDist = NavCog?.DecelerationDistance ?? 5.0;
|
||||
double maxLinearVel = DistanceToCheckingNode > decelerationDist
|
||||
? MaxLinearVelocity
|
||||
: MovePID.PID_step(DistanceToCheckingNode, MaxLinearVelocity, NavCog?.MinLinearVelocity ?? 0.01, dt);
|
||||
maxLinearVel = Math.Clamp(maxLinearVel, NavCog?.MinLinearVelocity ?? 0.01, MaxLinearVelocity);
|
||||
|
||||
(double linearVelActual, _) = VelController.ActualVelocity;
|
||||
(double LinearVel, double AngularVel) = MovePurePursuit.PurePursuit_step(Localization.X, Localization.Y, Localization.Theta, linearVelActual, maxLinearVel);
|
||||
|
||||
// Clamp angular velocity
|
||||
AngularVel = Math.Clamp(AngularVel, -(NavCog?.MaxAngularVelocity ?? 1.5), NavCog?.MaxAngularVelocity ?? 1.5);
|
||||
|
||||
// Clamp linear velocity (giữ dấu cho backward)
|
||||
var linearSign = Math.Sign(LinearVel);
|
||||
LinearVel = linearSign * Math.Clamp(Math.Abs(LinearVel), NavCog?.MinLinearVelocity ?? 0.01, MaxLinearVelocity);
|
||||
|
||||
VelController.SetVelocity(LinearVel, AngularVel);
|
||||
}
|
||||
else if (DistanceToGoal < reachedRadius)
|
||||
{
|
||||
VelController.SetVelocity(0, 0);
|
||||
if (MovePurePursuit.OrderNodes[^1].Theta is { } theta)
|
||||
{
|
||||
TargetAngle = theta;
|
||||
NavState = NavigationState.Rotating;
|
||||
RotatePID?.Reset();
|
||||
MovePurePursuit = null;
|
||||
}
|
||||
else
|
||||
{
|
||||
NavState = NavigationState.Completed;
|
||||
Logger.LogInformation($"Navigation Reached: Pose({Localization.X} - {Localization.Y} - {Localization.Theta})");
|
||||
Dispose();
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
double? targetAngle = null;
|
||||
if (GoalRotate.Theta is { } theta)
|
||||
{
|
||||
targetAngle = theta;
|
||||
ProcessedRotations.Add(GoalRotate.NodeId);
|
||||
BackToAngle = GoalRotate?.ContinueTheta;
|
||||
IsBackToPath = true;
|
||||
}
|
||||
|
||||
var newGoalRotate = FindNextRotateGoal();
|
||||
if (newGoalRotate is not null && newGoalRotate.NodeId != GoalRotate?.NodeId)
|
||||
{
|
||||
GoalRotate = newGoalRotate;
|
||||
UpdateGoal(newGoalRotate.NodeId);
|
||||
}
|
||||
|
||||
if (targetAngle.HasValue)
|
||||
{
|
||||
TargetAngle = targetAngle.Value;
|
||||
NavState = NavigationState.Rotating;
|
||||
RotatePID?.Reset();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void Docking()
|
||||
{
|
||||
if (MovePID is not null && DockToController is not null && DockToController.Waypoints_Value is not null && DockToController.Goal != null)
|
||||
{
|
||||
var dockCfg = DockToController.DockConfig;
|
||||
|
||||
// --- Continuous goal update from detection session ---
|
||||
TryUpdateDockGoalFromSession(NavCog.DockToMaxSpeed);
|
||||
|
||||
var DistanceToGoal = Math.Sqrt(Math.Pow(Localization.X - DockToController.Goal.X, 2) + Math.Pow(Localization.Y - DockToController.Goal.Y, 2));
|
||||
var deviation = dockCfg.ReachedRadius;
|
||||
|
||||
// Overshoot detection: phát hiện robot đi qua goal
|
||||
var dockOvershootRadius = dockCfg.DockingOvershootDetectionRadius;
|
||||
if (DistanceToGoal < dockOvershootRadius)
|
||||
{
|
||||
if (DistanceToGoal < _oldDistanceToGoal)
|
||||
{
|
||||
_wasApproaching = true;
|
||||
_overshootCounter = 0;
|
||||
}
|
||||
else if (_wasApproaching && DistanceToGoal > _oldDistanceToGoal)
|
||||
{
|
||||
_overshootCounter++;
|
||||
if (_wasApproaching && _overshootCounter >= _overshootOut)
|
||||
{
|
||||
_wasApproaching = false;
|
||||
_overshootCounter = 0;
|
||||
VelController.SetVelocity(0, 0);
|
||||
|
||||
if (_hasLoad)
|
||||
{
|
||||
// When loaded: no FinePositioning allowed, go to Error
|
||||
Logger.LogError($"Docking overshoot with load at distance {DistanceToGoal:F4}m. No FinePositioning allowed.");
|
||||
NavState = NavigationState.Error;
|
||||
Dispose();
|
||||
return;
|
||||
}
|
||||
|
||||
Logger.LogWarning(
|
||||
$"Overshoot detected at distance {DistanceToGoal:F4}m. " +
|
||||
$"Entering FinePositioning (attempt {_finePositioningRetryCount + 1}/{_finePositioningMaxRetries}).");
|
||||
|
||||
// Transition to FinePositioning instead of giving up
|
||||
_finePositioningCycleCount = 0;
|
||||
_finePositioningIsAligning = true;
|
||||
_finePositionRotatePID = new PID(NavigationConfig.GetRotatePidConfig());
|
||||
_oldDistanceToGoal = double.MaxValue;
|
||||
_fpWasApproaching = false;
|
||||
_fpOvershootCounter = 0;
|
||||
NavState = NavigationState.FinePositioning;
|
||||
return;
|
||||
}
|
||||
}
|
||||
else _overshootCounter = 0;
|
||||
}
|
||||
_oldDistanceToGoal = DistanceToGoal;
|
||||
|
||||
if (DistanceToGoal > deviation)
|
||||
{
|
||||
double dt = CycleHandlerMilliseconds / 1000.0;
|
||||
double dockDecelerationDist = dockCfg.DecelerationDistance;
|
||||
double maxLinearVel = DistanceToGoal > dockDecelerationDist
|
||||
? MaxLinearVelocity
|
||||
: MovePID.PID_step(DistanceToGoal, MaxLinearVelocity, NavCog?.MinLinearVelocity ?? 0.01, dt);
|
||||
maxLinearVel = Math.Clamp(maxLinearVel, NavCog?.MinLinearVelocity ?? 0.01, MaxLinearVelocity);
|
||||
|
||||
(double linearVelActual, _) = VelController.ActualVelocity;
|
||||
(double LinearVel, double AngularVel) = DockToController.FinalApproachController(Localization.X, Localization.Y, Localization.Theta, linearVelActual, maxLinearVel);
|
||||
|
||||
// Clamp angular velocity
|
||||
AngularVel = Math.Clamp(AngularVel, -(NavCog?.MaxAngularVelocity ?? 1.5), NavCog?.MaxAngularVelocity ?? 1.5);
|
||||
|
||||
// Clamp linear velocity (giữ dấu cho backward)
|
||||
var linearSign = Math.Sign(LinearVel);
|
||||
LinearVel = linearSign * Math.Clamp(Math.Abs(LinearVel), NavCog?.MinLinearVelocity ?? 0.01, MaxLinearVelocity);
|
||||
|
||||
VelController.SetVelocity(LinearVel, AngularVel);
|
||||
}
|
||||
else
|
||||
{
|
||||
Console.WriteLine($"Dock To reached. Pose= ({Localization.X} - {Localization.Y} - {Localization.Theta}), Distance to goal: {DistanceToGoal}");
|
||||
VelController.SetVelocity(0, 0);
|
||||
if (!_hasLoad && DockToController.Goal.Theta is { } theta)
|
||||
{
|
||||
TargetAngle = theta;
|
||||
NavState = NavigationState.Rotating;
|
||||
RotatePID?.Reset();
|
||||
DockToController = null;
|
||||
}
|
||||
else
|
||||
{
|
||||
NavState = NavigationState.Completed;
|
||||
Logger.LogInformation($"DockTo Reached: Pose({Localization.X} - {Localization.Y} - {Localization.Theta})");
|
||||
Dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void MovingStraight()
|
||||
{
|
||||
if (MovePID is not null && MoveStraightController is not null
|
||||
&& MoveStraightController.Waypoints_Value is not null && MoveStraightController.Goal != null)
|
||||
{
|
||||
var cfg = MoveStraightController.DockConfig;
|
||||
var DistanceToGoal = Math.Sqrt(
|
||||
Math.Pow(Localization.X - MoveStraightController.Goal.X, 2) +
|
||||
Math.Pow(Localization.Y - MoveStraightController.Goal.Y, 2));
|
||||
var deviation = cfg.ReachedRadius;
|
||||
|
||||
// Overshoot detection (same pattern as Moving)
|
||||
var overshootRadius = cfg.DockingOvershootDetectionRadius;
|
||||
if (DistanceToGoal < overshootRadius)
|
||||
{
|
||||
if (DistanceToGoal < _oldDistanceToGoal)
|
||||
{
|
||||
_wasApproaching = true;
|
||||
_overshootCounter = 0;
|
||||
}
|
||||
else if (_wasApproaching && DistanceToGoal > _oldDistanceToGoal)
|
||||
{
|
||||
_overshootCounter++;
|
||||
if (_wasApproaching && _overshootCounter >= _overshootOut)
|
||||
{
|
||||
_wasApproaching = false;
|
||||
VelController.SetVelocity(0, 0);
|
||||
|
||||
double acceptanceRadius = NavCog?.OvershootAcceptanceRadius ?? 0.15;
|
||||
if (DistanceToGoal > acceptanceRadius)
|
||||
{
|
||||
Logger.LogError($"MovingStraight overshoot too far: distance={DistanceToGoal:F4}m > acceptance={acceptanceRadius}m");
|
||||
NavState = NavigationState.Error;
|
||||
Dispose();
|
||||
return;
|
||||
}
|
||||
|
||||
Logger.LogWarning($"MovingStraight overshoot at {DistanceToGoal:F4}m (within acceptance). Completing.");
|
||||
NavState = NavigationState.Completed;
|
||||
Dispose();
|
||||
return;
|
||||
}
|
||||
}
|
||||
else _overshootCounter = 0;
|
||||
}
|
||||
_oldDistanceToGoal = DistanceToGoal;
|
||||
|
||||
if (DistanceToGoal > deviation)
|
||||
{
|
||||
double dt = CycleHandlerMilliseconds / 1000.0;
|
||||
double decelerationDist = cfg.DecelerationDistance;
|
||||
double maxLinearVel = DistanceToGoal > decelerationDist
|
||||
? MaxLinearVelocity
|
||||
: MovePID.PID_step(DistanceToGoal, MaxLinearVelocity, NavCog?.MinLinearVelocity ?? 0.01, dt);
|
||||
maxLinearVel = Math.Clamp(maxLinearVel, NavCog?.MinLinearVelocity ?? 0.01, MaxLinearVelocity);
|
||||
|
||||
(double linearVelActual, _) = VelController.ActualVelocity;
|
||||
(double LinearVel, double AngularVel) = MoveStraightController.FinalApproachController(
|
||||
Localization.X, Localization.Y, Localization.Theta, linearVelActual, maxLinearVel);
|
||||
|
||||
AngularVel = Math.Clamp(AngularVel, -(NavCog?.MaxAngularVelocity ?? 1.5), NavCog?.MaxAngularVelocity ?? 1.5);
|
||||
|
||||
// Clamp linear velocity (giữ dấu cho backward)
|
||||
var linearSign = Math.Sign(LinearVel);
|
||||
LinearVel = linearSign * Math.Clamp(Math.Abs(LinearVel), NavCog?.MinLinearVelocity ?? 0.01, MaxLinearVelocity);
|
||||
|
||||
VelController.SetVelocity(LinearVel, AngularVel);
|
||||
}
|
||||
else
|
||||
{
|
||||
Logger.LogInformation($"MoveStraight reached. Pose=({Localization.X} - {Localization.Y} - {Localization.Theta}), Distance: {DistanceToGoal}");
|
||||
VelController.SetVelocity(0, 0);
|
||||
NavState = NavigationState.Completed;
|
||||
Dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// FinePositioning: Cơ chế retry chính xác khi Docking overshoot.
|
||||
/// Phase 1 (Align): Xoay tại chỗ hướng về goal theo góc nhỏ nhất (forward hoặc backward).
|
||||
/// Hướng được lock 1 lần khi vào align, không thay đổi trong suốt quá trình xoay.
|
||||
/// Phase 2 (Advance): Tiến thẳng về goal ở DockToRetrySpeed với P-correction trên angular velocity.
|
||||
/// Success: distance ≤ ReachedRadius → final rotation hoặc Completed.
|
||||
/// Failure: hết retry hoặc timeout → Error.
|
||||
/// </summary>
|
||||
private void FinePositioning()
|
||||
{
|
||||
if (DockToController?.Goal is not { } goal)
|
||||
{
|
||||
Logger.LogError("FinePositioning: DockToController or Goal is null. Aborting.");
|
||||
NavState = NavigationState.Error;
|
||||
Dispose();
|
||||
return;
|
||||
}
|
||||
|
||||
// Continuously update goal from dock session (same as Docking phase)
|
||||
var updatedGoal = TryUpdateDockGoalFromSession(NavCog.DockToRetrySpeed);
|
||||
if (updatedGoal is not null) goal = updatedGoal;
|
||||
|
||||
// Per-attempt time-based timeout
|
||||
_finePositioningCycleCount++;
|
||||
int elapsedMs = _finePositioningCycleCount * CycleHandlerMilliseconds;
|
||||
if (elapsedMs > _finePositioningTimeoutMs)
|
||||
{
|
||||
_finePositioningRetryCount++;
|
||||
Logger.LogWarning(
|
||||
$"FinePositioning attempt {_finePositioningRetryCount} timed out " +
|
||||
$"(elapsed {elapsedMs}ms > {_finePositioningTimeoutMs}ms).");
|
||||
|
||||
if (_finePositioningRetryCount >= _finePositioningMaxRetries)
|
||||
{
|
||||
Logger.LogError(
|
||||
$"FinePositioning exhausted all {_finePositioningMaxRetries} retries. Transitioning to Error.");
|
||||
VelController.SetVelocity(0, 0);
|
||||
NavState = NavigationState.Error;
|
||||
Dispose();
|
||||
}
|
||||
else
|
||||
{
|
||||
VelController.SetVelocity(0, 0);
|
||||
_finePositioningCycleCount = 0;
|
||||
_finePositioningIsAligning = true;
|
||||
_finePositionRotatePID?.Reset();
|
||||
_oldDistanceToGoal = double.MaxValue;
|
||||
_fpWasApproaching = false;
|
||||
_fpOvershootCounter = 0;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
double robotX = Localization.X;
|
||||
double robotY = Localization.Y;
|
||||
double robotTheta = Localization.Theta;
|
||||
|
||||
double dx = goal.X - robotX;
|
||||
double dy = goal.Y - robotY;
|
||||
double distanceToGoal = Math.Sqrt(dx * dx + dy * dy);
|
||||
|
||||
// Success check
|
||||
double reachedRadius = DockToController.DockConfig.ReachedRadius;
|
||||
if (distanceToGoal <= reachedRadius)
|
||||
{
|
||||
Logger.LogInformation(
|
||||
$"FinePositioning SUCCESS: distance={distanceToGoal:F4}m, " +
|
||||
$"Pose({robotX:F3}, {robotY:F3}, {robotTheta:F3}) → Goal({goal.X:F3}, {goal.Y:F3}, {goal.Theta:F3}, {_dockSession?.Goal?.Header.FrameId}, {_dockSession?.Goal?.Header.Stamp}). ");
|
||||
VelController.SetVelocity(0, 0);
|
||||
|
||||
if (goal.Theta is { } finalTheta)
|
||||
{
|
||||
TargetAngle = finalTheta;
|
||||
NavState = NavigationState.Rotating;
|
||||
RotatePID = new PID(NavigationConfig.GetRotatePidConfig());
|
||||
DockToController = null;
|
||||
}
|
||||
else
|
||||
{
|
||||
NavState = NavigationState.Completed;
|
||||
DockToController = null;
|
||||
Dispose();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Chọn hướng tiếp cận có góc xoay nhỏ nhất (forward hoặc backward)
|
||||
// Direction chỉ được chọn 1 lần khi vào align phase (cycle đầu tiên),
|
||||
// sau đó giữ nguyên suốt align + advance để tránh flip do sensor noise.
|
||||
double rawHeading = Math.Atan2(dy, dx);
|
||||
|
||||
if (_finePositioningIsAligning && _finePositioningCycleCount == 1)
|
||||
{
|
||||
double fwdErr = SpaceCompute.NormalizeRadianAngle(rawHeading) - robotTheta;
|
||||
if (fwdErr > Math.PI) fwdErr -= 2 * Math.PI;
|
||||
else if (fwdErr < -Math.PI) fwdErr += 2 * Math.PI;
|
||||
|
||||
double bwdErr = SpaceCompute.NormalizeRadianAngle(rawHeading + Math.PI) - robotTheta;
|
||||
if (bwdErr > Math.PI) bwdErr -= 2 * Math.PI;
|
||||
else if (bwdErr < -Math.PI) bwdErr += 2 * Math.PI;
|
||||
|
||||
_finePositioningDirection = Math.Abs(fwdErr) <= Math.Abs(bwdErr)
|
||||
? RobotDirection.FORWARD
|
||||
: RobotDirection.BACKWARD;
|
||||
}
|
||||
|
||||
// Tính heading error theo hướng đã lock
|
||||
double targetHeading = _finePositioningDirection == RobotDirection.BACKWARD
|
||||
? SpaceCompute.NormalizeRadianAngle(rawHeading + Math.PI)
|
||||
: SpaceCompute.NormalizeRadianAngle(rawHeading);
|
||||
|
||||
double headingError = targetHeading - robotTheta;
|
||||
if (headingError > Math.PI) headingError -= 2 * Math.PI;
|
||||
else if (headingError < -Math.PI) headingError += 2 * Math.PI;
|
||||
|
||||
double FineAlignThresholdRad = (DockToController.DockConfig.FineAlignThresholdDegrees) * Math.PI / 180.0;
|
||||
double ReAlignThresholdRad = (DockToController.DockConfig.ReAlignThresholdDegrees) * Math.PI / 180.0;
|
||||
|
||||
// Overshoot detection during advance phase — consecutive increase pattern
|
||||
if (!_finePositioningIsAligning)
|
||||
{
|
||||
if (distanceToGoal < _oldDistanceToGoal)
|
||||
{
|
||||
_fpWasApproaching = true;
|
||||
_fpOvershootCounter = 0;
|
||||
}
|
||||
else if (_fpWasApproaching && distanceToGoal > _oldDistanceToGoal)
|
||||
{
|
||||
_fpOvershootCounter++;
|
||||
if (_fpOvershootCounter >= DockToController.DockConfig.FinePositioningOvershootCount)
|
||||
{
|
||||
_finePositioningRetryCount++;
|
||||
_fpWasApproaching = false;
|
||||
_fpOvershootCounter = 0;
|
||||
VelController.SetVelocity(0, 0);
|
||||
|
||||
if (_finePositioningRetryCount >= _finePositioningMaxRetries)
|
||||
{
|
||||
Logger.LogError("FinePositioning: repeated overshoot during advance. Giving up.");
|
||||
NavState = NavigationState.Error;
|
||||
Dispose();
|
||||
return;
|
||||
}
|
||||
|
||||
Logger.LogWarning(
|
||||
$"FinePositioning: overshoot during advance (dist={distanceToGoal:F4}m). " +
|
||||
$"Retry {_finePositioningRetryCount}/{_finePositioningMaxRetries}.");
|
||||
_finePositioningIsAligning = true;
|
||||
_finePositionRotatePID?.Reset();
|
||||
_finePositioningCycleCount = 0;
|
||||
_oldDistanceToGoal = double.MaxValue;
|
||||
return;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
_fpOvershootCounter = 0;
|
||||
}
|
||||
}
|
||||
_oldDistanceToGoal = distanceToGoal;
|
||||
|
||||
// Phase 1: ALIGN — xoay tại chỗ theo góc nhỏ nhất
|
||||
if (_finePositioningIsAligning)
|
||||
{
|
||||
if (Math.Abs(headingError) < FineAlignThresholdRad)
|
||||
{
|
||||
_finePositioningIsAligning = false;
|
||||
VelController.SetVelocity(0, 0);
|
||||
Logger.LogInformation(
|
||||
$"FinePositioning aligned ({_finePositioningDirection}): " +
|
||||
$"headingError={headingError * 180 / Math.PI:F1}°. Advancing.");
|
||||
}
|
||||
else
|
||||
{
|
||||
_finePositionRotatePID ??= new PID(NavigationConfig.GetRotatePidConfig());
|
||||
double rotateAngularVel = NavCog?.DockToRotateSpeed ?? 0.05;
|
||||
double angularCmd = _finePositionRotatePID.PID_step(
|
||||
headingError, rotateAngularVel, -rotateAngularVel, CycleHandlerMilliseconds / 1000.0);
|
||||
VelController.SetVelocity(0, angularCmd);
|
||||
Console.WriteLine(
|
||||
$"FinePos-Align({_finePositioningDirection}): " +
|
||||
$"HeadErr={headingError * 180 / Math.PI:F1}°, AngVel={angularCmd:F4}, Dist={distanceToGoal:F4}m");
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Phase 2: ADVANCE — tiến thẳng về goal theo hướng đã chọn ở Phase 1
|
||||
if (Math.Abs(headingError) > ReAlignThresholdRad)
|
||||
{
|
||||
_finePositioningIsAligning = true;
|
||||
_finePositionRotatePID?.Reset();
|
||||
VelController.SetVelocity(0, 0);
|
||||
Logger.LogWarning($"FinePositioning heading drift: {headingError * 180 / Math.PI:F1}°. Re-aligning.");
|
||||
return;
|
||||
}
|
||||
|
||||
double minLinVel = NavCog?.DockToRetrySpeed ?? 0.05;
|
||||
double linearCmd = _finePositioningDirection == RobotDirection.BACKWARD ? -minLinVel : minLinVel;
|
||||
|
||||
// Corrective angular velocity proportional to heading error
|
||||
// Dùng Max(|actualVel|, minLinVel) để đảm bảo correction không bằng 0 khi vừa bắt đầu advance
|
||||
(double linearVelActual, _) = VelController.ActualVelocity;
|
||||
double effectiveVel = Math.Max(Math.Abs(linearVelActual), minLinVel);
|
||||
double headingGain = DockToController.DockConfig.AdvanceHeadingCorrectionGain;
|
||||
double dockAdvanceMaxAngVel = DockToController.DockConfig.DockToAdvanceMaxAngularVelocity;
|
||||
double advanceAngularCorrection = Math.Clamp(headingError * headingGain * effectiveVel, -dockAdvanceMaxAngVel, dockAdvanceMaxAngVel);
|
||||
|
||||
VelController.SetVelocity(linearCmd, advanceAngularCorrection);
|
||||
Console.WriteLine(
|
||||
$"FinePos-Advance({_finePositioningDirection}): LinVel={linearCmd:F4}, AngCorr={advanceAngularCorrection:F4}, " +
|
||||
$"Dist={distanceToGoal:F4}m, HeadErr={headingError * 180 / Math.PI:F1}°");
|
||||
}
|
||||
|
||||
private NavigationNode? TryUpdateDockGoalFromSession(double speed)
|
||||
{
|
||||
if (_dockSession is null || DockToController?.DockConfig is not { } dockCfg)
|
||||
return null;
|
||||
|
||||
var snapshot = _dockSession.Goal;
|
||||
if (!snapshot.HasValue) return null;
|
||||
|
||||
var pose = snapshot.Value.Pose;
|
||||
|
||||
var oldGoal = DockToController!.Goal;
|
||||
double dxGoal = pose.Position.X - oldGoal.X;
|
||||
double dyGoal = pose.Position.Y - oldGoal.Y;
|
||||
double distShift = Math.Sqrt(dxGoal * dxGoal + dyGoal * dyGoal);
|
||||
|
||||
double newTheta = pose.Orientation.ToYawRadian();
|
||||
double oldTheta = oldGoal.Theta ?? 0;
|
||||
double angleShift = Math.Abs(SpaceCompute.NormalizeRadianAngle(newTheta - oldTheta));
|
||||
double maxAngleShiftRad = dockCfg.MaxGoalAngleShiftDegrees * Math.PI / 180.0;
|
||||
|
||||
if (distShift > dockCfg.MaxGoalPositionShift || angleShift > maxAngleShiftRad)
|
||||
return null;
|
||||
|
||||
var updatedGoal = new NavigationNode()
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
X = pose.Position.X,
|
||||
Y = pose.Position.Y,
|
||||
Speed = speed,
|
||||
Theta = newTheta,
|
||||
};
|
||||
|
||||
var newStartNode = GetDockToStartNode(
|
||||
Localization.X, Localization.Y,
|
||||
updatedGoal.X, updatedGoal.Y,
|
||||
updatedGoal.Theta ?? 0,
|
||||
dockCfg.DockToLength);
|
||||
|
||||
DockToController.WithPath(newStartNode, updatedGoal);
|
||||
return updatedGoal;
|
||||
}
|
||||
|
||||
private void NavigationHandler()
|
||||
{
|
||||
try
|
||||
{
|
||||
switch (NavState)
|
||||
{
|
||||
case NavigationState.Rotating:
|
||||
Rotating();
|
||||
break;
|
||||
case NavigationState.Moving:
|
||||
Moving();
|
||||
break;
|
||||
case NavigationState.Docking:
|
||||
Docking();
|
||||
break;
|
||||
case NavigationState.MovingStraight:
|
||||
MovingStraight();
|
||||
break;
|
||||
case NavigationState.FinePositioning:
|
||||
FinePositioning();
|
||||
break;
|
||||
case NavigationState.Paused:
|
||||
case NavigationState.SafetyStop:
|
||||
if (!_idleVelocityZeroSent)
|
||||
{
|
||||
VelController.SetVelocity(0, 0);
|
||||
_idleVelocityZeroSent = true;
|
||||
}
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
NavState = NavigationState.Error;
|
||||
Dispose();
|
||||
Logger.LogError($"Error in DifferentialNavigation: {ex.Message}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
namespace RobotNet10.RobotApp.Services.Navigation;
|
||||
|
||||
public interface IVelocityController
|
||||
{
|
||||
(double Linear, double Angular) ActualVelocity { get; }
|
||||
(double Linear, double Angular) RawVelocity { get; }
|
||||
void SetVelocity(double linearVel, double angularVel);
|
||||
void LoadConfig();
|
||||
double GetModelConfidence();
|
||||
void SetAcceleration(double acc);
|
||||
void SetDeceleration(double dec);
|
||||
bool EnsureInverseKinematicsReady(CancellationToken cancellationToken);
|
||||
}
|
||||
@@ -0,0 +1,428 @@
|
||||
using Microsoft.AspNetCore.SignalR;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using RobotNet10.Common;
|
||||
using RobotNet10.RobotApp.Hubs;
|
||||
using RobotNet10.RobotApp.Interfaces;
|
||||
using RobotNet10.RobotApp.Services.Navigation;
|
||||
using RobotNet10.RobotApp.Services.Robot.Modules;
|
||||
using RobotNet10.RobotApp.Services.Simulation;
|
||||
using RobotNet10.RobotApp.Shared.Enums;
|
||||
using RobotNet10.RobotApp.Shared.NavigationMonitor;
|
||||
|
||||
namespace RobotNet10.RobotApp.Services.NavigationMonitor;
|
||||
|
||||
/// <summary>
|
||||
/// Independent monitoring service for CSharpNavigation.
|
||||
/// Periodically reads robot state and broadcasts telemetry via SignalR.
|
||||
/// Can be enabled/disabled at runtime without affecting navigation.
|
||||
/// </summary>
|
||||
public class NavigationMonitorService : IHostedService, IDisposable
|
||||
{
|
||||
private const int DefaultIntervalMs = 100; // 10Hz internal tick (safety checks)
|
||||
private const int BroadcastEveryNTicks = 5; // Broadcast telemetry every 5 ticks = 2Hz
|
||||
|
||||
private readonly ILocalization _localization;
|
||||
private readonly IVelocityController _velocityController;
|
||||
private readonly RobotNavigation _navigation;
|
||||
private readonly IHubContext<NavigationMonitorHub> _hubContext;
|
||||
private readonly ILogger<NavigationMonitorService> _logger;
|
||||
|
||||
private WatchTimer<NavigationMonitorService>? _timer;
|
||||
private volatile bool _telemetryEnabled;
|
||||
private volatile bool _safetyStopEnabled;
|
||||
private NavigationSafetyConfigDto _safetyConfig = new();
|
||||
private readonly Lock _configLock = new();
|
||||
|
||||
// Safety stop latch state
|
||||
private volatile bool _safetyStopLatched;
|
||||
private string _safetyStopReason = "";
|
||||
private int _safetyStopGraceTicks;
|
||||
private const int SafetyStopGraceCount = 20; // 2s at 10Hz
|
||||
|
||||
// State for acceleration calculation
|
||||
private double _lastLinearVel;
|
||||
private double _lastAngularVel;
|
||||
private long _lastTimestampMs;
|
||||
|
||||
// Broadcast throttle counter
|
||||
private int _broadcastTickCounter;
|
||||
|
||||
public NavigationMonitorService(
|
||||
ILocalization localization,
|
||||
IVelocityController velocityController,
|
||||
RobotNavigation navigation,
|
||||
IHubContext<NavigationMonitorHub> hubContext,
|
||||
ILogger<NavigationMonitorService> logger)
|
||||
{
|
||||
_localization = localization;
|
||||
_velocityController = velocityController;
|
||||
_navigation = navigation;
|
||||
_hubContext = hubContext;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public Task StartAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
_logger.LogInformation("NavigationMonitorService started (telemetry disabled by default)");
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task StopAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
StopTimer();
|
||||
_logger.LogInformation("NavigationMonitorService stopped");
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public void SetTelemetryEnabled(bool enabled)
|
||||
{
|
||||
_telemetryEnabled = enabled;
|
||||
if (enabled)
|
||||
StartTimer();
|
||||
else
|
||||
StopTimer();
|
||||
|
||||
_logger.LogInformation("Navigation telemetry {State}", enabled ? "enabled" : "disabled");
|
||||
BroadcastStateAsync();
|
||||
}
|
||||
|
||||
public void SetSafetyStopEnabled(bool enabled)
|
||||
{
|
||||
_safetyStopEnabled = enabled;
|
||||
_logger.LogInformation("Navigation safety stop {State}", enabled ? "enabled" : "disabled");
|
||||
BroadcastStateAsync();
|
||||
}
|
||||
|
||||
public void UpdateSafetyConfig(NavigationSafetyConfigDto config)
|
||||
{
|
||||
lock (_configLock)
|
||||
{
|
||||
_safetyConfig = config;
|
||||
}
|
||||
_logger.LogInformation("Navigation safety config updated");
|
||||
BroadcastStateAsync();
|
||||
}
|
||||
|
||||
public NavigationMonitorStateDto GetState()
|
||||
{
|
||||
NavigationSafetyConfigDto configCopy;
|
||||
lock (_configLock)
|
||||
{
|
||||
configCopy = new NavigationSafetyConfigDto
|
||||
{
|
||||
MaxLinearVelocity = _safetyConfig.MaxLinearVelocity,
|
||||
MaxAngularVelocity = _safetyConfig.MaxAngularVelocity,
|
||||
MaxLinearAcceleration = _safetyConfig.MaxLinearAcceleration,
|
||||
MaxCrossTrackError = _safetyConfig.MaxCrossTrackError,
|
||||
MaxHeadingError = _safetyConfig.MaxHeadingError
|
||||
};
|
||||
}
|
||||
|
||||
return new NavigationMonitorStateDto
|
||||
{
|
||||
TelemetryEnabled = _telemetryEnabled,
|
||||
SafetyStopEnabled = _safetyStopEnabled,
|
||||
SafetyStopLatched = _safetyStopLatched,
|
||||
SafetyStopReason = _safetyStopReason,
|
||||
SafetyConfig = configCopy,
|
||||
UpdateFrequencyHz = 1000.0 / (DefaultIntervalMs * BroadcastEveryNTicks)
|
||||
};
|
||||
}
|
||||
|
||||
public void ReleaseSafetyStop()
|
||||
{
|
||||
if (!_safetyStopLatched) return;
|
||||
_safetyStopLatched = false;
|
||||
_safetyStopReason = "";
|
||||
_safetyStopGraceTicks = SafetyStopGraceCount;
|
||||
_navigation.Refresh();
|
||||
_logger.LogInformation("Safety stop released by user");
|
||||
BroadcastStateAsync();
|
||||
}
|
||||
|
||||
private void StartTimer()
|
||||
{
|
||||
StopTimer();
|
||||
_lastTimestampMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
|
||||
_lastLinearVel = 0;
|
||||
_lastAngularVel = 0;
|
||||
_broadcastTickCounter = 0;
|
||||
_timer = new WatchTimer<NavigationMonitorService>(DefaultIntervalMs, OnTick, _logger);
|
||||
_timer.Start();
|
||||
}
|
||||
|
||||
private void StopTimer()
|
||||
{
|
||||
_timer?.Dispose();
|
||||
_timer = null;
|
||||
}
|
||||
|
||||
private void OnTick()
|
||||
{
|
||||
try
|
||||
{
|
||||
var now = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
|
||||
var dtSeconds = (now - _lastTimestampMs) / 1000.0;
|
||||
if (dtSeconds <= 0) dtSeconds = DefaultIntervalMs / 1000.0;
|
||||
|
||||
// Read robot state
|
||||
var x = _localization.X;
|
||||
var y = _localization.Y;
|
||||
var theta = _localization.Theta;
|
||||
var (linearVel, angularVel) = _velocityController.ActualVelocity;
|
||||
var modelConfidence = _velocityController.GetModelConfidence();
|
||||
var navState = _navigation.State;
|
||||
var driving = _navigation.Driving;
|
||||
|
||||
// Calculate acceleration
|
||||
var linearAccel = (linearVel - _lastLinearVel) / dtSeconds;
|
||||
var angularAccel = (angularVel - _lastAngularVel) / dtSeconds;
|
||||
_lastLinearVel = linearVel;
|
||||
_lastAngularVel = angularVel;
|
||||
_lastTimestampMs = now;
|
||||
|
||||
// Calculate CTE and heading error if navigating with path
|
||||
double cte = 0;
|
||||
double headingError = 0;
|
||||
double distanceToGoal = 0;
|
||||
|
||||
var path = _navigation.CurrentPath; // capture once (volatile)
|
||||
if (path is { Count: >= 2 } && driving)
|
||||
{
|
||||
// Skip heading error check during Rotating/FinePositioning:
|
||||
// - Rotating: robot intentionally turning in place, heading diverges from path tangent
|
||||
// - FinePositioning: robot re-aligning to dock goal, heading changes rapidly
|
||||
bool skipHeadingCheck = navState is NavigationState.Rotating or NavigationState.FinePositioning;
|
||||
(cte, headingError) = CalculatePathErrors(x, y, theta, path, skipHeadingCheck);
|
||||
var goal = path[^1];
|
||||
distanceToGoal = Math.Sqrt((x - goal.X) * (x - goal.X) + (y - goal.Y) * (y - goal.Y));
|
||||
}
|
||||
|
||||
// DockTo-specific telemetry
|
||||
DockToTelemetryDto? dockToTelemetry = null;
|
||||
if (_navigation.IsDockingActive)
|
||||
{
|
||||
var dockGoal = _navigation.DockGoal;
|
||||
var dockStart = _navigation.DockStartNode;
|
||||
dockToTelemetry = new DockToTelemetryDto
|
||||
{
|
||||
Phase = _navigation.DockPhase,
|
||||
Direction = _navigation.DockDirection,
|
||||
RetryCount = _navigation.DockRetryCount,
|
||||
MaxRetries = _navigation.DockMaxRetries,
|
||||
GoalX = dockGoal?.X ?? 0,
|
||||
GoalY = dockGoal?.Y ?? 0,
|
||||
GoalTheta = dockGoal?.Theta ?? 0,
|
||||
TotalWaypoints = _navigation.DockWaypointCount,
|
||||
StartX = dockStart?.X ?? 0,
|
||||
StartY = dockStart?.Y ?? 0,
|
||||
StartTheta = dockStart?.Theta ?? 0,
|
||||
Waypoints = DownsampleWaypoints(_navigation.DockWaypoints, 30)
|
||||
};
|
||||
}
|
||||
|
||||
// Downsample current path waypoints for client visualization
|
||||
var waypoints = path is { Count: >= 2 } && driving
|
||||
? DownsampleNavigationWaypoints(path, 50)
|
||||
: [];
|
||||
|
||||
var telemetry = new NavigationTelemetryDto
|
||||
{
|
||||
TimestampMs = now,
|
||||
X = x,
|
||||
Y = y,
|
||||
Theta = theta,
|
||||
LinearVelocity = linearVel,
|
||||
AngularVelocity = angularVel,
|
||||
LinearAcceleration = double.IsFinite(linearAccel) ? linearAccel : 0,
|
||||
AngularAcceleration = double.IsFinite(angularAccel) ? angularAccel : 0,
|
||||
NavigationState = navState.ToString(),
|
||||
Driving = driving,
|
||||
CrossTrackError = cte,
|
||||
HeadingError = headingError,
|
||||
DistanceToGoal = distanceToGoal,
|
||||
ModelConfidence = modelConfidence,
|
||||
DockTo = dockToTelemetry,
|
||||
Waypoints = waypoints
|
||||
};
|
||||
|
||||
// Broadcast telemetry at reduced rate (2Hz) to save client resources
|
||||
_broadcastTickCounter++;
|
||||
if (_broadcastTickCounter >= BroadcastEveryNTicks)
|
||||
{
|
||||
_broadcastTickCounter = 0;
|
||||
_ = _hubContext.Clients.Group("monitor").SendAsync("ReceiveTelemetry", telemetry);
|
||||
}
|
||||
|
||||
// Auto-release latch if navigation ended while latched
|
||||
if (_safetyStopLatched && !driving)
|
||||
{
|
||||
_safetyStopLatched = false;
|
||||
_safetyStopReason = "";
|
||||
_navigation.Refresh();
|
||||
_logger.LogInformation("Safety stop auto-released: navigation ended");
|
||||
BroadcastStateAsync();
|
||||
}
|
||||
|
||||
// Grace period countdown after user release
|
||||
if (_safetyStopGraceTicks > 0) _safetyStopGraceTicks--;
|
||||
|
||||
// Safety checks
|
||||
NavigationSafetyConfigDto configSnapshot;
|
||||
lock (_configLock)
|
||||
{
|
||||
configSnapshot = new NavigationSafetyConfigDto
|
||||
{
|
||||
MaxLinearVelocity = _safetyConfig.MaxLinearVelocity,
|
||||
MaxAngularVelocity = _safetyConfig.MaxAngularVelocity,
|
||||
MaxLinearAcceleration = _safetyConfig.MaxLinearAcceleration,
|
||||
MaxCrossTrackError = _safetyConfig.MaxCrossTrackError,
|
||||
MaxHeadingError = _safetyConfig.MaxHeadingError
|
||||
};
|
||||
}
|
||||
|
||||
var violations = NavigationSafetyChecker.Check(telemetry, configSnapshot);
|
||||
if (violations.Count > 0)
|
||||
{
|
||||
foreach (var v in violations)
|
||||
{
|
||||
_ = _hubContext.Clients.Group("monitor").SendAsync("ReceiveSafetyViolation", v);
|
||||
}
|
||||
|
||||
// Latch safety stop on critical violations: Pause navigation + hold
|
||||
if (_safetyStopEnabled && !_safetyStopLatched
|
||||
&& _safetyStopGraceTicks == 0
|
||||
&& violations.Exists(v => v.Severity == SafetyViolationSeverity.Critical))
|
||||
{
|
||||
_safetyStopLatched = true;
|
||||
_safetyStopReason = violations.First(v => v.Severity == SafetyViolationSeverity.Critical).Message;
|
||||
_navigation.SafetyStop();
|
||||
_logger.LogWarning("Safety stop latched: {Reason}", _safetyStopReason);
|
||||
BroadcastStateAsync();
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error in NavigationMonitor tick");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Calculate cross-track error and heading error from current position to closest path point.
|
||||
/// </summary>
|
||||
private static (double cte, double headingError) CalculatePathErrors(
|
||||
double x, double y, double theta,
|
||||
IReadOnlyList<NavigationNode> path,
|
||||
bool skipHeadingCheck = false)
|
||||
{
|
||||
// Find closest point on path
|
||||
int closestIndex = 0;
|
||||
double minDist = double.MaxValue;
|
||||
|
||||
for (int i = 0; i < path.Count; i++)
|
||||
{
|
||||
var dx = x - path[i].X;
|
||||
var dy = y - path[i].Y;
|
||||
var dist = dx * dx + dy * dy;
|
||||
if (dist < minDist)
|
||||
{
|
||||
minDist = dist;
|
||||
closestIndex = i;
|
||||
}
|
||||
}
|
||||
|
||||
double cte = Math.Sqrt(minDist);
|
||||
|
||||
if (skipHeadingCheck)
|
||||
return (cte, 0);
|
||||
|
||||
// Calculate heading error using path tangent, accounting for Direction
|
||||
double headingError = 0;
|
||||
double refTheta;
|
||||
if (closestIndex < path.Count - 1)
|
||||
{
|
||||
var dx = path[closestIndex + 1].X - path[closestIndex].X;
|
||||
var dy = path[closestIndex + 1].Y - path[closestIndex].Y;
|
||||
refTheta = Math.Atan2(dy, dx);
|
||||
}
|
||||
else if (closestIndex > 0)
|
||||
{
|
||||
var dx = path[closestIndex].X - path[closestIndex - 1].X;
|
||||
var dy = path[closestIndex].Y - path[closestIndex - 1].Y;
|
||||
refTheta = Math.Atan2(dy, dx);
|
||||
}
|
||||
else
|
||||
{
|
||||
return (cte, 0);
|
||||
}
|
||||
|
||||
// When path segment is BACKWARD, robot faces opposite to path tangent
|
||||
if (path[closestIndex].Direction == RobotDirection.BACKWARD)
|
||||
refTheta = NormalizeAngle(refTheta + Math.PI);
|
||||
|
||||
headingError = NormalizeAngle(theta - refTheta);
|
||||
|
||||
return (cte, headingError);
|
||||
}
|
||||
|
||||
private static double NormalizeAngle(double angle)
|
||||
{
|
||||
while (angle > Math.PI) angle -= 2 * Math.PI;
|
||||
while (angle < -Math.PI) angle += 2 * Math.PI;
|
||||
return angle;
|
||||
}
|
||||
|
||||
private static List<DockWaypointDto> DownsampleWaypoints(IReadOnlyList<NavigationNode>? waypoints, int maxPoints)
|
||||
{
|
||||
if (waypoints is null or { Count: 0 }) return [];
|
||||
if (waypoints.Count <= maxPoints)
|
||||
return waypoints.Select(w => new DockWaypointDto { X = w.X, Y = w.Y }).ToList();
|
||||
|
||||
var result = new List<DockWaypointDto>(maxPoints);
|
||||
result.Add(new DockWaypointDto { X = waypoints[0].X, Y = waypoints[0].Y });
|
||||
|
||||
double step = (double)(waypoints.Count - 1) / (maxPoints - 1);
|
||||
for (int i = 1; i < maxPoints - 1; i++)
|
||||
{
|
||||
int idx = (int)Math.Round(i * step);
|
||||
result.Add(new DockWaypointDto { X = waypoints[idx].X, Y = waypoints[idx].Y });
|
||||
}
|
||||
|
||||
var last = waypoints[^1];
|
||||
result.Add(new DockWaypointDto { X = last.X, Y = last.Y });
|
||||
return result;
|
||||
}
|
||||
|
||||
private static List<WaypointDto> DownsampleNavigationWaypoints(IReadOnlyList<NavigationNode> waypoints, int maxPoints)
|
||||
{
|
||||
if (waypoints.Count <= maxPoints)
|
||||
return waypoints.Select(w => new WaypointDto { X = w.X, Y = w.Y, Direction = w.Direction.ToString() }).ToList();
|
||||
|
||||
var result = new List<WaypointDto>(maxPoints);
|
||||
result.Add(new WaypointDto { X = waypoints[0].X, Y = waypoints[0].Y, Direction = waypoints[0].Direction.ToString() });
|
||||
|
||||
double step = (double)(waypoints.Count - 1) / (maxPoints - 1);
|
||||
for (int i = 1; i < maxPoints - 1; i++)
|
||||
{
|
||||
int idx = (int)Math.Round(i * step);
|
||||
result.Add(new WaypointDto { X = waypoints[idx].X, Y = waypoints[idx].Y, Direction = waypoints[idx].Direction.ToString() });
|
||||
}
|
||||
|
||||
var last = waypoints[^1];
|
||||
result.Add(new WaypointDto { X = last.X, Y = last.Y, Direction = last.Direction.ToString() });
|
||||
return result;
|
||||
}
|
||||
|
||||
private void BroadcastStateAsync()
|
||||
{
|
||||
var state = GetState();
|
||||
_ = _hubContext.Clients.Group("monitor").SendAsync("ReceiveMonitorState", state);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
StopTimer();
|
||||
GC.SuppressFinalize(this);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
using RobotNet10.RobotApp.Shared.NavigationMonitor;
|
||||
|
||||
namespace RobotNet10.RobotApp.Services.NavigationMonitor;
|
||||
|
||||
/// <summary>
|
||||
/// Stateless safety checker: evaluates telemetry against configurable thresholds.
|
||||
/// </summary>
|
||||
public static class NavigationSafetyChecker
|
||||
{
|
||||
public static List<NavigationSafetyViolationDto> Check(
|
||||
NavigationTelemetryDto telemetry,
|
||||
NavigationSafetyConfigDto config)
|
||||
{
|
||||
var violations = new List<NavigationSafetyViolationDto>();
|
||||
var now = telemetry.TimestampMs;
|
||||
|
||||
// 1. Linear velocity
|
||||
if (Math.Abs(telemetry.LinearVelocity) > config.MaxLinearVelocity)
|
||||
{
|
||||
violations.Add(new NavigationSafetyViolationDto
|
||||
{
|
||||
TimestampMs = now,
|
||||
Type = SafetyViolationType.LinearVelocity,
|
||||
Severity = SafetyViolationSeverity.Warning,
|
||||
Value = Math.Abs(telemetry.LinearVelocity),
|
||||
Threshold = config.MaxLinearVelocity,
|
||||
Message = $"Linear velocity {Math.Abs(telemetry.LinearVelocity):F2} m/s exceeds limit {config.MaxLinearVelocity:F2} m/s"
|
||||
});
|
||||
}
|
||||
|
||||
// 2. Angular velocity
|
||||
if (Math.Abs(telemetry.AngularVelocity) > config.MaxAngularVelocity)
|
||||
{
|
||||
violations.Add(new NavigationSafetyViolationDto
|
||||
{
|
||||
TimestampMs = now,
|
||||
Type = SafetyViolationType.AngularVelocity,
|
||||
Severity = SafetyViolationSeverity.Warning,
|
||||
Value = Math.Abs(telemetry.AngularVelocity),
|
||||
Threshold = config.MaxAngularVelocity,
|
||||
Message = $"Angular velocity {Math.Abs(telemetry.AngularVelocity):F2} rad/s exceeds limit {config.MaxAngularVelocity:F2} rad/s"
|
||||
});
|
||||
}
|
||||
|
||||
// 3. Linear acceleration
|
||||
if (Math.Abs(telemetry.LinearAcceleration) > config.MaxLinearAcceleration)
|
||||
{
|
||||
violations.Add(new NavigationSafetyViolationDto
|
||||
{
|
||||
TimestampMs = now,
|
||||
Type = SafetyViolationType.LinearAcceleration,
|
||||
Severity = SafetyViolationSeverity.Warning,
|
||||
Value = Math.Abs(telemetry.LinearAcceleration),
|
||||
Threshold = config.MaxLinearAcceleration,
|
||||
Message = $"Linear acceleration {Math.Abs(telemetry.LinearAcceleration):F2} m/s² exceeds limit {config.MaxLinearAcceleration:F2} m/s²"
|
||||
});
|
||||
}
|
||||
|
||||
// 4. Cross-track error (only when navigating and path is available)
|
||||
double maxCteRadians = config.MaxCrossTrackError;
|
||||
if (telemetry.Driving && telemetry.CrossTrackError > maxCteRadians)
|
||||
{
|
||||
violations.Add(new NavigationSafetyViolationDto
|
||||
{
|
||||
TimestampMs = now,
|
||||
Type = SafetyViolationType.CrossTrackError,
|
||||
Severity = SafetyViolationSeverity.Critical,
|
||||
Value = telemetry.CrossTrackError,
|
||||
Threshold = config.MaxCrossTrackError,
|
||||
Message = $"CTE {telemetry.CrossTrackError:F3} m exceeds limit {config.MaxCrossTrackError:F3} m"
|
||||
});
|
||||
}
|
||||
|
||||
// 5. Heading error (only when navigating and path is available)
|
||||
double maxHeadingRadians = config.MaxHeadingError * Math.PI / 180.0;
|
||||
if (telemetry.Driving && Math.Abs(telemetry.HeadingError) > maxHeadingRadians)
|
||||
{
|
||||
violations.Add(new NavigationSafetyViolationDto
|
||||
{
|
||||
TimestampMs = now,
|
||||
Type = SafetyViolationType.HeadingError,
|
||||
Severity = SafetyViolationSeverity.Critical,
|
||||
Value = Math.Abs(telemetry.HeadingError) * 180.0 / Math.PI,
|
||||
Threshold = config.MaxHeadingError,
|
||||
Message = $"Heading error {Math.Abs(telemetry.HeadingError) * 180.0 / Math.PI:F1}° exceeds limit {config.MaxHeadingError:F1}°"
|
||||
});
|
||||
}
|
||||
|
||||
return violations;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
using RobotNet10.RobotApp.Hubs;
|
||||
|
||||
namespace RobotNet10.RobotApp.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Broadcast odometry tới SignalR clients định kỳ (mặc định 10 Hz) để hiển thị realtime
|
||||
/// </summary>
|
||||
public class OdometryBroadcastService : IHostedService, IDisposable
|
||||
{
|
||||
private readonly OdometryHubContext _odometryHubContext;
|
||||
private readonly ILogger<OdometryBroadcastService> _logger;
|
||||
private Timer? _timer;
|
||||
private const int BroadcastIntervalMs = 100; // 10 Hz
|
||||
|
||||
public OdometryBroadcastService(OdometryHubContext odometryHubContext, ILogger<OdometryBroadcastService> logger)
|
||||
{
|
||||
_odometryHubContext = odometryHubContext;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public Task StartAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
_timer = new Timer(
|
||||
async _ =>
|
||||
{
|
||||
try
|
||||
{
|
||||
await _odometryHubContext.BroadcastOdometryAsync();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogDebug(ex, "Odometry broadcast skipped");
|
||||
}
|
||||
},
|
||||
null,
|
||||
TimeSpan.FromMilliseconds(BroadcastIntervalMs),
|
||||
TimeSpan.FromMilliseconds(BroadcastIntervalMs));
|
||||
_logger.LogInformation("Odometry broadcast started at {Hz} Hz", 1000.0 / BroadcastIntervalMs);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task StopAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
_timer?.Change(Timeout.Infinite, 0);
|
||||
_logger.LogInformation("Odometry broadcast stopped");
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_timer?.Dispose();
|
||||
GC.SuppressFinalize(this);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
using RobotNet.VDA5050.Type;
|
||||
using RobotNet10.RobotApp.Interfaces;
|
||||
|
||||
namespace RobotNet10.RobotApp.Services.Robot.Actions;
|
||||
|
||||
/// <summary>
|
||||
/// Tắt đèn camera (M918 - Light on OFF).
|
||||
/// </summary>
|
||||
[RobotAction(ActionType.CAMERA_LIGHT_OFF,
|
||||
[ActionScope.INSTANT, ActionScope.NODE, ActionScope.EDGE],
|
||||
[BlockingType.NONE, BlockingType.SOFT, BlockingType.HARD],
|
||||
"Tắt đèn camera (M918 Light on OFF).",
|
||||
"Đèn camera đã tắt (M918 Light on OFF).")]
|
||||
public class CameraLightOffAction(IServiceProvider ServiceProvider) : RobotAction(ServiceProvider)
|
||||
{
|
||||
private IPlcController? PlcController;
|
||||
|
||||
private const int MAX_TIMEOUT = 4000;
|
||||
private int CountTimeout = 0;
|
||||
|
||||
protected override Task StartAction()
|
||||
{
|
||||
PlcController = ServiceProvider.GetRequiredService<IPlcController>();
|
||||
PlcController.SetLightOn(false); // M918 Light on OFF
|
||||
return base.StartAction();
|
||||
}
|
||||
|
||||
protected override Task ExecuteAction()
|
||||
{
|
||||
if (PlcController is not null && !PlcController.SetLightOnValue)
|
||||
{
|
||||
SetStatus(ActionEvent.FINISHED);
|
||||
ResultDescription = string.IsNullOrEmpty(ActionAttribute.ResultDescription) ? ResultDescription : ActionAttribute.ResultDescription;
|
||||
}
|
||||
else if (CountTimeout++ > MAX_TIMEOUT / ActionInterval)
|
||||
{
|
||||
SetStatus(ActionEvent.FAILED);
|
||||
ResultDescription = "Timeout action";
|
||||
}
|
||||
|
||||
return base.ExecuteAction();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
using RobotNet.VDA5050.Type;
|
||||
using RobotNet10.RobotApp.Interfaces;
|
||||
|
||||
namespace RobotNet10.RobotApp.Services.Robot.Actions;
|
||||
|
||||
/// <summary>
|
||||
/// Bật đèn camera (M918 - coil 2966).
|
||||
/// action tương tự các action PLC toggle khác: ghi ON, rồi chờ PLC phản hồi đã ON.
|
||||
/// </summary>
|
||||
[RobotAction(ActionType.CAMERA_LIGHT_ON,
|
||||
[ActionScope.INSTANT, ActionScope.NODE, ActionScope.EDGE],
|
||||
[BlockingType.NONE, BlockingType.SOFT, BlockingType.HARD],
|
||||
"Bật đèn camera (M918 Light on).",
|
||||
"Đèn camera đã bật (M918 Light on).")]
|
||||
public class CameraLightOnAction(IServiceProvider ServiceProvider) : RobotAction(ServiceProvider)
|
||||
{
|
||||
private IPlcController? PlcController;
|
||||
|
||||
private const int MAX_TIMEOUT = 4000;
|
||||
private int CountTimeout = 0;
|
||||
|
||||
protected override Task StartAction()
|
||||
{
|
||||
PlcController = ServiceProvider.GetRequiredService<IPlcController>();
|
||||
PlcController.SetLightOn(true); // M918 Light on
|
||||
return base.StartAction();
|
||||
}
|
||||
|
||||
protected override Task ExecuteAction()
|
||||
{
|
||||
if (PlcController is not null && PlcController.SetLightOnValue)
|
||||
{
|
||||
SetStatus(ActionEvent.FINISHED);
|
||||
ResultDescription = string.IsNullOrEmpty(ActionAttribute.ResultDescription) ? ResultDescription : ActionAttribute.ResultDescription;
|
||||
}
|
||||
else if (CountTimeout++ > MAX_TIMEOUT / ActionInterval)
|
||||
{
|
||||
SetStatus(ActionEvent.FAILED);
|
||||
ResultDescription = "Timeout action";
|
||||
}
|
||||
|
||||
return base.ExecuteAction();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
using RobotNet.VDA5050.Type;
|
||||
using RobotNet10.RobotApp.Devices;
|
||||
using RobotNet10.RobotApp.Services.Exceptions;
|
||||
using RobotNet10.RobotApp.Interfaces;
|
||||
|
||||
namespace RobotNet10.RobotApp.Services.Robot.Actions;
|
||||
|
||||
/// <summary>
|
||||
/// Bật đèn camera (M918 - coil 2966).
|
||||
/// action tương tự các action PLC toggle khác: ghi ON, rồi chờ PLC phản hồi đã ON.
|
||||
/// </summary>
|
||||
[RobotAction(ActionType.CONTROL_LIGHT,
|
||||
[ActionScope.INSTANT, ActionScope.NODE, ActionScope.EDGE],
|
||||
[BlockingType.NONE, BlockingType.SOFT, BlockingType.HARD],
|
||||
"Bật đèn camera (M918 Light on).",
|
||||
"Đèn camera đã bật (M918 Light on).")]
|
||||
public class ControlLightAction(IServiceProvider ServiceProvider) : RobotAction(ServiceProvider)
|
||||
{
|
||||
private IPlcController? PlcController;
|
||||
|
||||
private const int MAX_TIMEOUT = 4000;
|
||||
private int CountTimeout = 0;
|
||||
private bool IsLightOn = false;
|
||||
|
||||
|
||||
protected override void Initialize()
|
||||
{
|
||||
base.Initialize();
|
||||
|
||||
var controlTypeParam = Action?.ActionParameters?.FirstOrDefault(p =>
|
||||
string.Equals(p.Key, "CONTROL_TYPE", StringComparison.OrdinalIgnoreCase));
|
||||
if (controlTypeParam is null || string.IsNullOrWhiteSpace(controlTypeParam.Value))
|
||||
{
|
||||
throw new ActionException("ControlLight requires actionParameter CONTROL_TYPE (CONTROL_ON/CONTROL_OFF).");
|
||||
}
|
||||
|
||||
var controlType = controlTypeParam.Value.Trim();
|
||||
if (string.Equals(controlType, "CONTROL_ON", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
IsLightOn = true;
|
||||
}
|
||||
else if (string.Equals(controlType, "CONTROL_OFF", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
IsLightOn = false;
|
||||
}
|
||||
else
|
||||
{
|
||||
throw new ActionException($"CONTROL_TYPE value '{controlType}' is invalid. Expected CONTROL_ON or CONTROL_OFF.");
|
||||
}
|
||||
|
||||
CountTimeout = 0;
|
||||
}
|
||||
|
||||
protected override Task StartAction()
|
||||
{
|
||||
PlcController = ServiceProvider.GetRequiredService<IPlcController>();
|
||||
PlcController.SetLightOn(IsLightOn); // M918 Light on
|
||||
return base.StartAction();
|
||||
}
|
||||
|
||||
protected override Task ExecuteAction()
|
||||
{
|
||||
if (PlcController is not null && PlcController.SetLightOnValue == IsLightOn)
|
||||
{
|
||||
SetStatus(ActionEvent.FINISHED);
|
||||
ResultDescription = string.IsNullOrEmpty(ActionAttribute.ResultDescription) ? ResultDescription : ActionAttribute.ResultDescription;
|
||||
}
|
||||
else if (CountTimeout++ > MAX_TIMEOUT / ActionInterval)
|
||||
{
|
||||
SetStatus(ActionEvent.FAILED);
|
||||
ResultDescription = "Timeout action";
|
||||
}
|
||||
|
||||
return base.ExecuteAction();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
using RobotNet.VDA5050.Type;
|
||||
using RobotNet10.RobotApp.Devices;
|
||||
|
||||
namespace RobotNet10.RobotApp.Services.Robot.Actions;
|
||||
|
||||
/// <summary>
|
||||
/// Action homing camera (lift): gọi trực tiếp xuống động cơ CiA402 (giống device/hub).
|
||||
/// blockingType NONE: gửi lệnh homing và kết thúc ngay, không chờ hoàn thành.
|
||||
/// </summary>
|
||||
[RobotAction(ActionType.HOMING_CAMERA,
|
||||
[ActionScope.INSTANT, ActionScope.NODE, ActionScope.EDGE],
|
||||
[BlockingType.NONE],
|
||||
"Homing camera (lift).",
|
||||
"Homing camera requested.")]
|
||||
public class HomingCameraAction(IServiceProvider ServiceProvider) : RobotAction(ServiceProvider)
|
||||
{
|
||||
protected override async Task StartAction()
|
||||
{
|
||||
try
|
||||
{
|
||||
var config = ServiceProvider.GetRequiredService<IConfiguration>();
|
||||
var deviceProvider = ServiceProvider.GetRequiredService<IDeviceProvider>();
|
||||
|
||||
var deviceId = config.GetValue<string>("Modules:LiftModule:DeviceId") ?? "lift-motor";
|
||||
var enable = config.GetValue<bool>("Modules:LiftModule:Enable", true);
|
||||
if (!enable)
|
||||
{
|
||||
SetStatus(ActionEvent.FAILED);
|
||||
ResultDescription = "Lift module is disabled in config.";
|
||||
return;
|
||||
}
|
||||
|
||||
var device = deviceProvider.GetDevice(deviceId);
|
||||
if (device is not ICiA402Servo servo)
|
||||
{
|
||||
SetStatus(ActionEvent.FAILED);
|
||||
ResultDescription = $"Device '{deviceId}' not found or is not ICiA402Servo.";
|
||||
return;
|
||||
}
|
||||
|
||||
var homingMethod = config.GetValue<byte>("Modules:LiftModule:HomingMethod", 21);
|
||||
var homingSpeed = config.GetValue<int>("Modules:LiftModule:HomingSpeed", 30000);
|
||||
var homingOffset = config.GetValue<int>("Modules:LiftModule:HomingOffset", 0);
|
||||
|
||||
// Giống device UI: bước 1 SetParam (Apply Params), bước 2 Start Homing
|
||||
Logger?.LogInformation("HomingCamera: SetParam (method={Method}, speed={Speed}, offset={Offset}) then Start Homing.", homingMethod, homingSpeed, homingOffset);
|
||||
await servo.SetHomingMethodAsync(homingMethod, CancellationToken.None);
|
||||
await servo.SetHomingSpeedAsync(homingSpeed, CancellationToken.None);
|
||||
await servo.SetHomingOffsetAsync(homingOffset, CancellationToken.None);
|
||||
await Task.Delay(100, CancellationToken.None); // delay giữa SetParam và Start như khi bấm trên device
|
||||
await servo.StartHomingAsync(homingMethod, homingSpeed, CancellationToken.None);
|
||||
|
||||
SetStatus(ActionEvent.FINISHED);
|
||||
ResultDescription = "Homing camera requested (direct to drive).";
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
SetStatus(ActionEvent.FAILED);
|
||||
ResultDescription = $"Homing camera failed: {ex.Message}";
|
||||
}
|
||||
|
||||
await base.StartAction();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,250 @@
|
||||
// using RobotNet.VDA5050.Type;
|
||||
// using RobotNet10.RobotApp.Devices;
|
||||
// using RobotNet10.RobotApp.Services.Exceptions;
|
||||
// using System.Globalization;
|
||||
|
||||
// namespace RobotNet10.RobotApp.Services.Robot.Actions;
|
||||
|
||||
// /// <summary>
|
||||
// /// Action đưa camera (lift) tới vị trí theo chiều cao (m).
|
||||
// /// Gọi trực tiếp xuống động cơ CiA402 (giống device/hub), không qua LiftModule state machine.
|
||||
// /// actionParameters: HEIGHT (unit: m), ví dụ "0.825".
|
||||
// /// blockingType NONE: gửi lệnh di chuyển và kết thúc ngay, không chờ hoàn thành.
|
||||
// /// </summary>
|
||||
// [RobotAction(ActionType.LIFT_CAMERA_BY_HEIGHT,
|
||||
// [ActionScope.INSTANT, ActionScope.NODE, ActionScope.EDGE],
|
||||
// [BlockingType.NONE, BlockingType.HARD, BlockingType.SOFT],
|
||||
// "Lift camera with height (unit: m).",
|
||||
// "Lift camera move requested.")]
|
||||
// public class LiftCameraByHeightAction(IServiceProvider ServiceProvider) : RobotAction(ServiceProvider)
|
||||
// {
|
||||
// private double _heightM;
|
||||
// private double _timeoutMs = 20 * 1000;
|
||||
|
||||
// protected override void Initialize()
|
||||
// {
|
||||
// base.Initialize();
|
||||
|
||||
// var heightParam = Action?.ActionParameters?.FirstOrDefault(p =>
|
||||
// string.Equals(p.Key, "HEIGHT", StringComparison.OrdinalIgnoreCase));
|
||||
// if (heightParam is null || string.IsNullOrWhiteSpace(heightParam.Value))
|
||||
// {
|
||||
// throw new ActionException("LiftCameraByHeight requires actionParameter HEIGHT (unit: m).");
|
||||
// }
|
||||
|
||||
// if (!double.TryParse(heightParam.Value, NumberStyles.Any, CultureInfo.InvariantCulture, out _heightM))
|
||||
// {
|
||||
// throw new ActionException($"HEIGHT value '{heightParam.Value}' is not a valid number.");
|
||||
// }
|
||||
// }
|
||||
|
||||
// protected override async Task StartAction()
|
||||
// {
|
||||
// try
|
||||
// {
|
||||
// var config = ServiceProvider.GetRequiredService<IConfiguration>();
|
||||
// var deviceProvider = ServiceProvider.GetRequiredService<IDeviceProvider>();
|
||||
|
||||
// var deviceId = config.GetValue<string>("Modules:LiftModule:DeviceId") ?? "lift-motor";
|
||||
// var enable = config.GetValue<bool>("Modules:LiftModule:Enable", true);
|
||||
// if (!enable)
|
||||
// {
|
||||
// SetStatus(ActionEvent.FAILED);
|
||||
// ResultDescription = "Lift module is disabled in config.";
|
||||
// return;
|
||||
// }
|
||||
|
||||
// var device = deviceProvider.GetDevice(deviceId);
|
||||
// if (device is not ICiA402Servo servo)
|
||||
// {
|
||||
// SetStatus(ActionEvent.FAILED);
|
||||
// ResultDescription = $"Device '{deviceId}' not found or is not ICiA402Servo.";
|
||||
// return;
|
||||
// }
|
||||
|
||||
// // Đọc config để map height (m) -> position (encoder), giống LiftModuleService.GetPositionFromHeightM
|
||||
// var minHeightM = config.GetValue<double>("Modules:LiftModule:MinHeightM", 0);
|
||||
// var maxHeightM = config.GetValue<double>("Modules:LiftModule:MaxHeightM", 1);
|
||||
// var minPosition = config.GetValue<int>("Modules:LiftModule:MinPosition", 0);
|
||||
// var maxPosition = config.GetValue<int>("Modules:LiftModule:MaxPosition", 1000000);
|
||||
|
||||
// int position;
|
||||
// if (maxHeightM <= minHeightM)
|
||||
// {
|
||||
// position = minPosition;
|
||||
// }
|
||||
// else
|
||||
// {
|
||||
// var t = Math.Clamp((_heightM - minHeightM) / (maxHeightM - minHeightM), 0, 1);
|
||||
// position = minPosition + (int)(t * (maxPosition - minPosition));
|
||||
// }
|
||||
|
||||
// var velocity = config.GetValue<uint>("Modules:LiftModule:ProfileVelocity", 100000);
|
||||
// var acceleration = config.GetValue<uint>("Modules:LiftModule:ProfileAcceleration", 50000);
|
||||
// var deceleration = config.GetValue<uint>("Modules:LiftModule:ProfileDeceleration", 50000);
|
||||
|
||||
// Logger?.LogInformation("LiftCameraByHeight: calling servo directly (deviceId={DeviceId}), MoveToPositionAsync(position={Position}, height={HeightM} m).", deviceId, position, _heightM);
|
||||
// await servo.MoveToPositionAsync(position, velocity, acceleration, deceleration, CancellationToken.None);
|
||||
|
||||
// // Timeout = (int)_timeoutMs;
|
||||
// SetStatus(ActionEvent.FINISHED);
|
||||
// ResultDescription = $"Lift camera move to height {_heightM} m requested (direct to drive).";
|
||||
// }
|
||||
// catch (Exception ex)
|
||||
// {
|
||||
// SetStatus(ActionEvent.FAILED);
|
||||
// ResultDescription = $"Lift camera by height failed: {ex.Message}";
|
||||
// }
|
||||
|
||||
// await base.StartAction();
|
||||
// }
|
||||
|
||||
// // protected override async Task ExecuteAction()
|
||||
// // {
|
||||
// // // to do: wait for the lift camera to reach the target height
|
||||
|
||||
// // }
|
||||
|
||||
// // protected override async Task CleanupAction()
|
||||
// // {
|
||||
// // // to do: cleanup the lift camera
|
||||
// // }
|
||||
// }
|
||||
|
||||
using RobotNet.VDA5050.Type;
|
||||
using RobotNet10.CANOpen.CiA402.Enums;
|
||||
using RobotNet10.RobotApp.Devices;
|
||||
using RobotNet10.RobotApp.Services.Exceptions;
|
||||
using System.Globalization;
|
||||
|
||||
namespace RobotNet10.RobotApp.Services.Robot.Actions;
|
||||
|
||||
/// <summary>
|
||||
/// Action đưa camera (lift) tới vị trí theo chiều cao (m).
|
||||
/// Gọi trực tiếp xuống động cơ CiA402 (giống device/hub), không qua LiftModule state machine.
|
||||
/// actionParameters: HEIGHT (unit: m), ví dụ "0.825".
|
||||
/// blockingType NONE: gửi lệnh di chuyển và kết thúc ngay, không chờ hoàn thành.
|
||||
/// </summary>
|
||||
[RobotAction(ActionType.LIFT_CAMERA_BY_HEIGHT,
|
||||
[ActionScope.INSTANT, ActionScope.NODE, ActionScope.EDGE],
|
||||
[BlockingType.NONE, BlockingType.HARD],
|
||||
"Lift camera with height (unit: m).",
|
||||
"Lift camera move requested.")]
|
||||
public class LiftCameraByHeightAction(IServiceProvider ServiceProvider) : RobotAction(ServiceProvider)
|
||||
{
|
||||
private double _heightM;
|
||||
|
||||
protected override void Initialize()
|
||||
{
|
||||
base.Initialize();
|
||||
|
||||
var heightParam = Action?.ActionParameters?.FirstOrDefault(p =>
|
||||
string.Equals(p.Key, "HEIGHT", StringComparison.OrdinalIgnoreCase));
|
||||
if (heightParam is null || string.IsNullOrWhiteSpace(heightParam.Value))
|
||||
{
|
||||
throw new ActionException("LiftCameraByHeight requires actionParameter HEIGHT (unit: m).");
|
||||
}
|
||||
|
||||
if (!double.TryParse(heightParam.Value, NumberStyles.Any, CultureInfo.InvariantCulture, out _heightM))
|
||||
{
|
||||
throw new ActionException($"HEIGHT value '{heightParam.Value}' is not a valid number.");
|
||||
}
|
||||
}
|
||||
|
||||
protected override async Task StartAction()
|
||||
{
|
||||
try
|
||||
{
|
||||
var config = ServiceProvider.GetRequiredService<IConfiguration>();
|
||||
var deviceProvider = ServiceProvider.GetRequiredService<IDeviceProvider>();
|
||||
|
||||
var deviceId = config.GetValue<string>("Modules:LiftModule:DeviceId") ?? "lift-motor";
|
||||
var enable = config.GetValue<bool>("Modules:LiftModule:Enable", true);
|
||||
if (!enable)
|
||||
{
|
||||
SetStatus(ActionEvent.FAILED);
|
||||
ResultDescription = "Lift module is disabled in config.";
|
||||
return;
|
||||
}
|
||||
|
||||
var device = deviceProvider.GetDevice(deviceId);
|
||||
if (device is not ICiA402Servo servo)
|
||||
{
|
||||
SetStatus(ActionEvent.FAILED);
|
||||
ResultDescription = $"Device '{deviceId}' not found or is not ICiA402Servo.";
|
||||
return;
|
||||
}
|
||||
|
||||
// Đọc config để map height (m) -> position (encoder), giống LiftModuleService.GetPositionFromHeightM
|
||||
var minHeightM = config.GetValue<double>("Modules:LiftModule:MinHeightM", 0);
|
||||
var maxHeightM = config.GetValue<double>("Modules:LiftModule:MaxHeightM", 1);
|
||||
var minPosition = config.GetValue<int>("Modules:LiftModule:MinPosition", 0);
|
||||
var maxPosition = config.GetValue<int>("Modules:LiftModule:MaxPosition", 1000000);
|
||||
|
||||
int position;
|
||||
if (maxHeightM <= minHeightM)
|
||||
{
|
||||
position = minPosition;
|
||||
}
|
||||
else
|
||||
{
|
||||
var t = Math.Clamp((_heightM - minHeightM) / (maxHeightM - minHeightM), 0, 1);
|
||||
position = minPosition + (int)(t * (maxPosition - minPosition));
|
||||
}
|
||||
|
||||
var velocity = config.GetValue<uint>("Modules:LiftModule:ProfileVelocity", 100000);
|
||||
var acceleration = config.GetValue<uint>("Modules:LiftModule:ProfileAcceleration", 50000);
|
||||
var deceleration = config.GetValue<uint>("Modules:LiftModule:ProfileDeceleration", 50000);
|
||||
var tolerance = config.GetValue<int>("Modules:LiftModule:ActionTargetTolerance", 900);
|
||||
var checkIntervalMs = config.GetValue<int>("Modules:LiftModule:ActionStatusWordCheckIntervalMs", 100);
|
||||
var timeoutMs = config.GetValue<int>("Modules:LiftModule:ActionMoveTimeoutMs", 300000);
|
||||
|
||||
Logger?.LogInformation("LiftCameraByHeight: calling servo directly (deviceId={DeviceId}), MoveToPositionAsync(position={Position}, height={HeightM} m).", deviceId, position, _heightM);
|
||||
await servo.MoveToPositionAsync(position, velocity, acceleration, deceleration, CancellationToken.None);
|
||||
await WaitForMovementCompletedAsync(servo, position, tolerance, checkIntervalMs, timeoutMs, CancellationToken.None);
|
||||
|
||||
SetStatus(ActionEvent.FINISHED);
|
||||
ResultDescription = $"Lift camera reached height {_heightM} m (direct to drive).";
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
SetStatus(ActionEvent.FAILED);
|
||||
ResultDescription = $"Lift camera by height failed: {ex.Message}";
|
||||
}
|
||||
|
||||
await base.StartAction();
|
||||
}
|
||||
|
||||
private static async Task WaitForMovementCompletedAsync(
|
||||
ICiA402Servo servo,
|
||||
int targetPosition,
|
||||
int tolerance,
|
||||
int checkIntervalMs,
|
||||
int timeoutMs,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var startedAt = DateTime.UtcNow;
|
||||
var pollInterval = Math.Max(10, checkIntervalMs);
|
||||
var maxWait = TimeSpan.FromMilliseconds(Math.Max(1000, timeoutMs));
|
||||
|
||||
while (DateTime.UtcNow - startedAt < maxWait)
|
||||
{
|
||||
var statusword = await servo.GetStatuswordAsync(ct);
|
||||
if (statusword.GetState() == DriveState.Fault)
|
||||
{
|
||||
throw new ActionException("Lift movement failed: servo entered fault state.");
|
||||
}
|
||||
|
||||
var currentPosition = await servo.GetActualPositionAsync(ct);
|
||||
if (statusword.TargetReached && Math.Abs(currentPosition - targetPosition) <= Math.Max(50, tolerance))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
await Task.Delay(pollInterval, ct);
|
||||
}
|
||||
|
||||
throw new TimeoutException($"Lift movement timeout after {maxWait.TotalSeconds:F0}s.");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,360 @@
|
||||
using Appccelerate.StateMachine;
|
||||
using Appccelerate.StateMachine.Machine;
|
||||
using RobotNet.VDA5050;
|
||||
using RobotNet.VDA5050.Type;
|
||||
using RobotNet10.Common;
|
||||
using RobotNet10.NavigationTune.Shared.Models;
|
||||
using RobotNet10.RobotApp.Services.Exceptions;
|
||||
using System.Diagnostics;
|
||||
using System.Reflection;
|
||||
|
||||
namespace RobotNet10.RobotApp.Services.Robot.Actions;
|
||||
|
||||
public abstract class RobotAction : IAsyncDisposable
|
||||
{
|
||||
public ActionType Type { get; }
|
||||
public string Id { get; private set; } = "";
|
||||
public string? Description { get; private set; }
|
||||
public BlockingType BlockingType { get; private set; }
|
||||
public RobotNet.VDA5050.InstantAction.ActionParameter[] Parameters { get; protected set; } = [];
|
||||
public ActionStatus Status => CurrentStatus;
|
||||
public string ResultDescription { get; set; } = "";
|
||||
public bool IsCompleted => CurrentStatus == ActionStatus.FINISHED || CurrentStatus == ActionStatus.FAILED;
|
||||
public long CompletionTime { get; private set; } = 0;
|
||||
public ActionScope ActionScope { get; set; }
|
||||
public RobotActionAttribute ActionAttribute { get; }
|
||||
public long SequenceNumber { get; internal set; }
|
||||
|
||||
private WatchThreadAsync<RobotAction>? ActionTimer;
|
||||
protected const int ActionInterval = 100;
|
||||
|
||||
protected IServiceProvider ServiceProvider;
|
||||
protected RobotNet.VDA5050.InstantAction.Action? Action;
|
||||
protected ILogger<RobotAction>? Logger;
|
||||
protected bool IsPaused = false;
|
||||
protected ActionStatus HistoryStatus;
|
||||
private bool _justResumed = false;
|
||||
|
||||
private bool _isDisposed = false;
|
||||
private bool IsCancelAction = false;
|
||||
private PassiveStateMachine<ActionStatus, ActionEvent>? _stateMachine;
|
||||
private ActionStatus CurrentStatus;
|
||||
private long StartTime;
|
||||
private int Timeout;
|
||||
|
||||
private readonly Lock _lock = new();
|
||||
|
||||
public RobotAction(IServiceProvider serviceProvider)
|
||||
{
|
||||
var derivedType = GetType();
|
||||
|
||||
ActionAttribute = derivedType.GetCustomAttribute<RobotActionAttribute>()
|
||||
?? throw new InvalidOperationException(
|
||||
$"Class {derivedType.Name} must have RobotActionAttribute");
|
||||
|
||||
Type = ActionAttribute.ActionType;
|
||||
ServiceProvider = serviceProvider;
|
||||
|
||||
Logger = ServiceProvider.GetRequiredService<ILogger<RobotAction>>();
|
||||
InitializeStatus();
|
||||
}
|
||||
|
||||
public void Initialize(ActionScope actionScope, RobotNet.VDA5050.InstantAction.Action action)
|
||||
{
|
||||
ActionScope = actionScope;
|
||||
Action = action;
|
||||
BlockingType = action.BlockingType;
|
||||
Id = action.ActionId;
|
||||
Description = action.ActionDescription;
|
||||
Initialize();
|
||||
}
|
||||
|
||||
public void Start()
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
if (Status != ActionStatus.WAITING) return;
|
||||
ActionTimer = new(ActionInterval, ActionHandler, Logger);
|
||||
SetStatus(ActionEvent.INITIALIZING);
|
||||
ActionTimer.Start();
|
||||
StartTime = GetCurrentTimeMs();
|
||||
}
|
||||
}
|
||||
|
||||
public void Pause()
|
||||
{
|
||||
if (_isDisposed) return;
|
||||
lock (_lock)
|
||||
{
|
||||
if (IsCompleted) return;
|
||||
HistoryStatus = Status;
|
||||
SetStatus(ActionEvent.PAUSED);
|
||||
IsPaused = true;
|
||||
}
|
||||
}
|
||||
|
||||
public void Resume()
|
||||
{
|
||||
if (_isDisposed) return;
|
||||
lock (_lock)
|
||||
{
|
||||
if (Status == ActionStatus.PAUSED)
|
||||
{
|
||||
IsPaused = false;
|
||||
_justResumed = true;
|
||||
// Restore về trạng thái trước khi pause
|
||||
ActionEvent resumeEvent = HistoryStatus switch
|
||||
{
|
||||
ActionStatus.RUNNING => ActionEvent.RUNNING,
|
||||
ActionStatus.INITIALIZING => ActionEvent.INITIALIZING,
|
||||
_ => ActionEvent.WAITING
|
||||
};
|
||||
SetStatus(resumeEvent);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public void Cancel()
|
||||
{
|
||||
if (_isDisposed) return;
|
||||
lock (_lock)
|
||||
{
|
||||
if (!IsCompleted) IsCancelAction = true;
|
||||
if (Status == ActionStatus.WAITING) _ = StopAction();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// VDA5050: Gracefully finish an action (e.g., when robot leaves an edge).
|
||||
/// Sets status to FINISHED instead of FAILED (Cancel).
|
||||
/// </summary>
|
||||
public void Finish()
|
||||
{
|
||||
if (_isDisposed) return;
|
||||
lock (_lock)
|
||||
{
|
||||
if (IsCompleted) return;
|
||||
// State machine allows FINISHED from RUNNING and INITIALIZING
|
||||
if (CurrentStatus == ActionStatus.RUNNING || CurrentStatus == ActionStatus.INITIALIZING)
|
||||
{
|
||||
SetStatus(ActionEvent.FINISHED);
|
||||
if (string.IsNullOrEmpty(ResultDescription))
|
||||
ResultDescription = "Action completed (edge transition).";
|
||||
}
|
||||
else
|
||||
{
|
||||
// WAITING or PAUSED: cancel as fallback
|
||||
IsCancelAction = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
protected virtual Task StartAction()
|
||||
{
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
protected virtual Task StopAction()
|
||||
{
|
||||
SetStatus(ActionEvent.FAILED);
|
||||
ResultDescription = "Action bị hủy bỏ.";
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
protected virtual Task CompleteAction()
|
||||
{
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Giải phóng tài nguyên được sử dụng trong action.
|
||||
/// Luôn được gọi trong DisposeAsync, đảm bảo cleanup cả khi FINISHED lẫn FAILED/Cancel.
|
||||
/// </summary>
|
||||
protected virtual Task CleanupAction()
|
||||
{
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
protected virtual Task ExecuteAction()
|
||||
{
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
protected virtual Task PauseAction()
|
||||
{
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
protected virtual Task ResumeAction()
|
||||
{
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
protected virtual void Initialize()
|
||||
{
|
||||
if (Action is null) throw new ActionException("Khởi tạo Action không tồn tại");
|
||||
if (EnumHelper.TryParse(Action.ActionType, out ActionType type))
|
||||
{
|
||||
if (type != Type) throw new ActionException($"ActionType {Action.ActionType} không khớp với action hiện tại {Type}.");
|
||||
}
|
||||
else throw new ActionException($"ActionType {Action.ActionType} không hợp lệ.");
|
||||
|
||||
if (!ActionAttribute.BlockingTypes.Any(bt => bt == BlockingType)) throw new ActionException($"BlockingType {BlockingType} không được hỗ trợ cho action {Type}.");
|
||||
if (!ActionAttribute.ActionScopes.Any(sp => sp == ActionScope)) throw new ActionException($"ActionScope {ActionScope} không được hỗ trợ cho action {Type}.");
|
||||
|
||||
if (Action.ActionParameters != null && Action.ActionParameters.Length > 0)
|
||||
{
|
||||
var para = Action.ActionParameters.FirstOrDefault(p => p.Key == "timeout");
|
||||
if (para is not null && int.TryParse(para.Value, out int miliseconds) && miliseconds > 100) Timeout = miliseconds;
|
||||
else Timeout = -1;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task ActionHandler()
|
||||
{
|
||||
try
|
||||
{
|
||||
if (Timeout > 0)
|
||||
{
|
||||
long now = GetCurrentTimeMs();
|
||||
if (now >= (StartTime + Timeout)) throw new TimeoutException($"Action [{Type} - {Id}] timeout. Timeout: {Timeout} ms ");
|
||||
}
|
||||
|
||||
ActionStatus status;
|
||||
bool isCancel = false;
|
||||
bool justResumed = false;
|
||||
lock (_lock)
|
||||
{
|
||||
status = CurrentStatus;
|
||||
isCancel = IsCancelAction;
|
||||
justResumed = _justResumed;
|
||||
if (_justResumed) _justResumed = false;
|
||||
}
|
||||
|
||||
if (isCancel)
|
||||
{
|
||||
await StopAction();
|
||||
}
|
||||
else
|
||||
{
|
||||
if (status == ActionStatus.INITIALIZING)
|
||||
{
|
||||
Logger?.LogInformation($"Executing action {Type}");
|
||||
SetStatus(ActionEvent.RUNNING);
|
||||
await StartAction();
|
||||
}
|
||||
else if (status == ActionStatus.RUNNING)
|
||||
{
|
||||
if (justResumed)
|
||||
{
|
||||
await ResumeAction();
|
||||
}
|
||||
await ExecuteAction();
|
||||
}
|
||||
else if (status == ActionStatus.PAUSED)
|
||||
{
|
||||
await PauseAction();
|
||||
}
|
||||
}
|
||||
|
||||
if (IsCompleted)
|
||||
{
|
||||
await CompleteAction();
|
||||
await DisposeAsync();
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger?.LogError($"Action [{Type} - {Id}] execution error: {ex.Message}");
|
||||
lock (_lock)
|
||||
{
|
||||
SetStatus(ActionEvent.FAILED);
|
||||
}
|
||||
ResultDescription = $"Thực hiện action [{Type} - {Id}] xảy ra lỗi: {ex.Message}";
|
||||
await DisposeAsync();
|
||||
}
|
||||
}
|
||||
|
||||
private void InitializeStatus()
|
||||
{
|
||||
var builder = new StateMachineDefinitionBuilder<ActionStatus, ActionEvent>();
|
||||
|
||||
builder.In(ActionStatus.WAITING)
|
||||
.ExecuteOnEntry(() => { CurrentStatus = ActionStatus.WAITING; })
|
||||
.On(ActionEvent.INITIALIZING).Goto(ActionStatus.INITIALIZING)
|
||||
.On(ActionEvent.PAUSED).Goto(ActionStatus.PAUSED)
|
||||
.On(ActionEvent.FAILED).Goto(ActionStatus.FAILED);
|
||||
|
||||
|
||||
builder.In(ActionStatus.INITIALIZING)
|
||||
.ExecuteOnEntry(() => { CurrentStatus = ActionStatus.INITIALIZING; })
|
||||
.On(ActionEvent.RUNNING).Goto(ActionStatus.RUNNING)
|
||||
.On(ActionEvent.PAUSED).Goto(ActionStatus.PAUSED)
|
||||
.On(ActionEvent.FINISHED).Goto(ActionStatus.FINISHED)
|
||||
.On(ActionEvent.FAILED).Goto(ActionStatus.FAILED);
|
||||
|
||||
builder.In(ActionStatus.RUNNING)
|
||||
.ExecuteOnEntry(() => { CurrentStatus = ActionStatus.RUNNING; })
|
||||
.On(ActionEvent.PAUSED).Goto(ActionStatus.PAUSED)
|
||||
.On(ActionEvent.FINISHED).Goto(ActionStatus.FINISHED)
|
||||
.On(ActionEvent.FAILED).Goto(ActionStatus.FAILED);
|
||||
|
||||
builder.In(ActionStatus.PAUSED)
|
||||
.ExecuteOnEntry(() => { CurrentStatus = ActionStatus.PAUSED; })
|
||||
.On(ActionEvent.WAITING).Goto(ActionStatus.WAITING)
|
||||
.On(ActionEvent.INITIALIZING).Goto(ActionStatus.INITIALIZING)
|
||||
.On(ActionEvent.RUNNING).Goto(ActionStatus.RUNNING)
|
||||
.On(ActionEvent.FAILED).Goto(ActionStatus.FAILED);
|
||||
|
||||
builder.In(ActionStatus.FINISHED)
|
||||
.ExecuteOnEntry(() =>
|
||||
{
|
||||
CurrentStatus = ActionStatus.FINISHED;
|
||||
CompletionTime = GetCurrentTimeMs();
|
||||
});
|
||||
|
||||
builder.In(ActionStatus.FAILED)
|
||||
.ExecuteOnEntry(() =>
|
||||
{
|
||||
CurrentStatus = ActionStatus.FAILED;
|
||||
CompletionTime = GetCurrentTimeMs();
|
||||
});
|
||||
|
||||
_stateMachine = builder
|
||||
.WithInitialState(ActionStatus.WAITING)
|
||||
.Build()
|
||||
.CreatePassiveStateMachine();
|
||||
|
||||
_stateMachine.Start();
|
||||
}
|
||||
|
||||
private static long GetCurrentTimeMs()
|
||||
{
|
||||
return Stopwatch.GetTimestamp() * 1000 / Stopwatch.Frequency;
|
||||
}
|
||||
|
||||
protected void SetStatus(ActionEvent eventStatus)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
_stateMachine?.Fire(eventStatus);
|
||||
}
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
bool shouldStop;
|
||||
lock (_lock)
|
||||
{
|
||||
if (_isDisposed) return;
|
||||
_isDisposed = true;
|
||||
shouldStop = !IsCompleted;
|
||||
}
|
||||
|
||||
if (shouldStop) await StopAction();
|
||||
await CleanupAction();
|
||||
ActionTimer?.Dispose();
|
||||
ActionTimer = null;
|
||||
GC.SuppressFinalize(this);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
using RobotNet.VDA5050.Factsheet;
|
||||
using RobotNet.VDA5050.Type;
|
||||
|
||||
namespace RobotNet10.RobotApp.Services.Robot.Actions;
|
||||
|
||||
[AttributeUsage(AttributeTargets.Class, AllowMultiple = false, Inherited = false)]
|
||||
public class RobotActionAttribute : Attribute
|
||||
{
|
||||
public ActionType ActionType { get; }
|
||||
public IReadOnlyList<ActionScope> ActionScopes { get; }
|
||||
public IReadOnlyList<BlockingType> BlockingTypes { get; }
|
||||
public string? ActionDescription { get; }
|
||||
public string? ResultDescription { get; }
|
||||
public RobotActionAttribute(
|
||||
ActionType actionType,
|
||||
ActionScope[] scopes,
|
||||
BlockingType[] blockingTypes,
|
||||
string? description = null,
|
||||
string? resultDescription = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(scopes);
|
||||
ArgumentNullException.ThrowIfNull(blockingTypes);
|
||||
|
||||
if (!Enum.IsDefined(actionType))
|
||||
{
|
||||
throw new ArgumentException("Invalid action type.", nameof(actionType));
|
||||
}
|
||||
|
||||
if (scopes.Length == 0)
|
||||
throw new ArgumentException("Scopes cannot be empty.", nameof(scopes));
|
||||
if (blockingTypes.Length == 0)
|
||||
throw new ArgumentException("Blocking types cannot be empty.", nameof(blockingTypes));
|
||||
|
||||
ActionType = actionType;
|
||||
ActionScopes = Array.AsReadOnly(scopes);
|
||||
BlockingTypes = Array.AsReadOnly(blockingTypes);
|
||||
ActionDescription = description;
|
||||
ResultDescription = resultDescription;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
using RobotNet.VDA5050.Factsheet;
|
||||
using RobotNet.VDA5050.Type;
|
||||
using RobotNet10.RobotApp.Services.Exceptions;
|
||||
using System.Collections.ObjectModel;
|
||||
|
||||
namespace RobotNet10.RobotApp.Services.Robot.Actions;
|
||||
|
||||
[RobotAction(ActionType.EXAMPLE,
|
||||
[ActionScope.INSTANT, ActionScope.NODE, ActionScope.EDGE],
|
||||
[BlockingType.NONE, BlockingType.SOFT, BlockingType.HARD],
|
||||
"This is an example robot action.",
|
||||
"Example robot action completed.")]
|
||||
public class RobotActionExample(IServiceProvider serviceProvider) : RobotAction(serviceProvider)
|
||||
{
|
||||
private static readonly ReadOnlyCollection<ActionParameter> ActionParameters = new List<ActionParameter>
|
||||
{
|
||||
new() {
|
||||
Key = "exampleParam",
|
||||
ValueDataType = ValueDataType.STRING,
|
||||
Description = "This is an example string parameter.",
|
||||
IsOptional = true
|
||||
},
|
||||
}.AsReadOnly();
|
||||
|
||||
string paramExample = "";
|
||||
|
||||
protected override void Initialize()
|
||||
{
|
||||
base.Initialize();
|
||||
|
||||
if (ActionParameters.Count > 0)
|
||||
{
|
||||
foreach (var parameterStore in ActionParameters)
|
||||
{
|
||||
if (!parameterStore.IsOptional)
|
||||
{
|
||||
if (Action is null || Action.ActionParameters is null || !Action.ActionParameters.Any(a => a.Key == parameterStore.Key)) throw new ActionException($"Thiếu tham số bắt buộc '{parameterStore.Key}' cho action {Type}.");
|
||||
}
|
||||
}
|
||||
Parameters = Action is null || Action.ActionParameters is null ? [] : Action.ActionParameters;
|
||||
}
|
||||
|
||||
var para = Parameters.FirstOrDefault(p => p.Key == "exampleParam") ?? throw new ActionException($"Action {Type} không tìm thấy parameter key 'exampleParam'");
|
||||
paramExample = para.Value?.ToString() ?? "";
|
||||
}
|
||||
|
||||
protected override Task StartAction()
|
||||
{
|
||||
return base.StartAction();
|
||||
}
|
||||
|
||||
protected override Task ExecuteAction()
|
||||
{
|
||||
return base.ExecuteAction();
|
||||
}
|
||||
|
||||
protected override Task StopAction()
|
||||
{
|
||||
return base.StopAction();
|
||||
}
|
||||
|
||||
protected override Task PauseAction()
|
||||
{
|
||||
return base.PauseAction();
|
||||
}
|
||||
|
||||
protected override Task ResumeAction()
|
||||
{
|
||||
return base.ResumeAction();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
using RobotNet.VDA5050.Type;
|
||||
using RobotNet10.RobotApp.Interfaces;
|
||||
|
||||
namespace RobotNet10.RobotApp.Services.Robot.Actions;
|
||||
|
||||
|
||||
public class RobotActionProvider(Logger<RobotActionProvider> Logger, IServiceProvider ServiceProvider) : BackgroundService, IRobotActionProvider
|
||||
{
|
||||
public bool IsInitialized { get; private set; }
|
||||
private Dictionary<ActionType, Type> Actions = [];
|
||||
|
||||
public RobotAction GetRobotAction(ActionType type)
|
||||
{
|
||||
if (!Actions.TryGetValue(type, out var actionType))
|
||||
{
|
||||
Logger.Error($"RobotAction not found for ActionType: {type}");
|
||||
throw new InvalidOperationException($"RobotAction not found for ActionType: {type}");
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var instance = ActivatorUtilities.CreateInstance(ServiceProvider, actionType);
|
||||
|
||||
if (instance is not RobotAction robotAction)
|
||||
{
|
||||
Logger.Error($"Type {actionType.Name} is not a RobotAction");
|
||||
throw new InvalidOperationException($"Type {actionType.Name} is not a RobotAction");
|
||||
}
|
||||
|
||||
return robotAction;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.Error($"Error creating instance of {actionType.Name}: {ex.Message}");
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
private static Dictionary<ActionType, Type> DiscoverActions()
|
||||
{
|
||||
var actionTypes = new Dictionary<ActionType, Type>();
|
||||
var assemblies = AppDomain.CurrentDomain.GetAssemblies().Where(a => a.FullName?.StartsWith("RobotNet10.RobotApp") == true);
|
||||
foreach (var assembly in assemblies)
|
||||
{
|
||||
var types = assembly.GetTypes().Where(t => t.IsClass && !t.IsAbstract && t.IsSubclassOf(typeof(RobotAction)));
|
||||
foreach (var type in types)
|
||||
{
|
||||
var attributes = type.GetCustomAttributes(typeof(RobotActionAttribute), false);
|
||||
if (attributes.Length > 0)
|
||||
{
|
||||
foreach(var attribute in attributes)
|
||||
{
|
||||
if(attribute is RobotActionAttribute robotAttribute)
|
||||
{
|
||||
actionTypes[robotAttribute.ActionType] = type;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return actionTypes;
|
||||
}
|
||||
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
await Task.Yield();
|
||||
|
||||
try
|
||||
{
|
||||
Logger.Info("Initializing RobotActionProvider...");
|
||||
Actions = DiscoverActions();
|
||||
IsInitialized = true;
|
||||
Logger.Info($"Discovered {Actions.Count} robot actions.");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.Warning($"Failed to discover robot actions. {ex}");
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
public RobotAction[] GetRobotActions()
|
||||
{
|
||||
try
|
||||
{
|
||||
List<RobotAction> robotActions = [];
|
||||
foreach (var actionType in Actions.Values)
|
||||
{
|
||||
var instance = ActivatorUtilities.CreateInstance(ServiceProvider, actionType);
|
||||
if (instance is not RobotAction robotAction)
|
||||
{
|
||||
Logger.Error($"Type {actionType.Name} is not a RobotAction");
|
||||
throw new InvalidOperationException($"Type {actionType.Name} is not a RobotAction");
|
||||
}
|
||||
robotActions.Add(robotAction);
|
||||
}
|
||||
return [.. robotActions];
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
throw new Exception ($"Error get RobotActions: {ex.Message}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
using RobotNet.VDA5050.Type;
|
||||
using RobotNet10.RobotApp.Interfaces;
|
||||
|
||||
namespace RobotNet10.RobotApp.Services.Robot.Actions;
|
||||
|
||||
|
||||
[RobotAction(ActionType.CANCEL_ORDER,
|
||||
[ActionScope.INSTANT],
|
||||
[BlockingType.NONE, BlockingType.SOFT, BlockingType.HARD],
|
||||
"Hủy bỏ Order hiện tại của robot.",
|
||||
"Robot đã hủy bỏ Order hiện tại.")]
|
||||
public class RobotCancelOrderAction(IServiceProvider ServiceProvider) : RobotAction(ServiceProvider)
|
||||
{
|
||||
private const int MAX_TIMEOUT = 4000;
|
||||
private int CountTimeout = 0;
|
||||
|
||||
private IOrder? RobotOrder;
|
||||
private IAction? RobotAction;
|
||||
protected override Task StartAction()
|
||||
{
|
||||
RobotOrder = ServiceProvider.GetRequiredService<IOrder>();
|
||||
RobotAction = ServiceProvider.GetRequiredService<IAction>();
|
||||
RobotOrder.StopOrder();
|
||||
RobotAction.StopOrderAction();
|
||||
CountTimeout = 0;
|
||||
return base.StartAction();
|
||||
}
|
||||
|
||||
protected override Task StopAction()
|
||||
{
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
protected override Task ExecuteAction()
|
||||
{
|
||||
if (RobotOrder is null || RobotAction is null)
|
||||
{
|
||||
SetStatus(ActionEvent.FAILED);
|
||||
ResultDescription = $"Không thể tìm thấy module quản lý {(RobotOrder is null ? "Order" : RobotAction is null ? "Action" : "")}";
|
||||
}
|
||||
else
|
||||
{
|
||||
if (RobotOrder.NodeStates.Length == 0 && RobotOrder.EdgeStates.Length == 0 && !RobotAction.HasActionRunning)
|
||||
{
|
||||
SetStatus(ActionEvent.FINISHED);
|
||||
ResultDescription = string.IsNullOrEmpty(ActionAttribute.ResultDescription) ? ResultDescription : ActionAttribute.ResultDescription;
|
||||
}
|
||||
else if(CountTimeout++ > MAX_TIMEOUT / ActionInterval)
|
||||
{
|
||||
SetStatus(ActionEvent.FAILED);
|
||||
ResultDescription = "Timeout action";
|
||||
}
|
||||
}
|
||||
return base.ExecuteAction();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using RobotNet.VDA5050.Factsheet;
|
||||
using RobotNet.VDA5050.Type;
|
||||
using RobotNet10.RobotApp.Data;
|
||||
using RobotNet10.RobotApp.Detection;
|
||||
using RobotNet10.RobotApp.Interfaces;
|
||||
using RobotNet10.RobotApp.Services.Exceptions;
|
||||
using RobotNet10.RobotApp.Shared.Enums;
|
||||
using RobotNet10.Shared.Detection;
|
||||
using RobotNet10.Shared.Enum;
|
||||
using RobotNet10.Shared.Numbers;
|
||||
using System.Collections.ObjectModel;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace RobotNet10.RobotApp.Services.Robot.Actions;
|
||||
|
||||
[RobotAction(ActionType.DOCK_TO,
|
||||
[ActionScope.INSTANT, ActionScope.NODE],
|
||||
[BlockingType.HARD],
|
||||
"Robot di chuyển vào vị trí đặc biệt",
|
||||
"Robot đã dock đến vị trí sạc hoặc bến đỗ.")]
|
||||
public class RobotDockToAction(IServiceProvider ServiceProvider) : RobotAction(ServiceProvider)
|
||||
{
|
||||
private static readonly ReadOnlyCollection<ActionParameter> ActionParameters = new List<ActionParameter>
|
||||
{
|
||||
new()
|
||||
{
|
||||
Key = "stationId",
|
||||
Description = "ID của vị trí dock.",
|
||||
ValueDataType = ValueDataType.STRING,
|
||||
IsOptional = false,
|
||||
},
|
||||
new()
|
||||
{
|
||||
Key = "direction",
|
||||
Description = "Hướng di chuyển: FORWARD, BACKWARD",
|
||||
ValueDataType = ValueDataType.STRING,
|
||||
IsOptional = true,
|
||||
},
|
||||
}.AsReadOnly();
|
||||
|
||||
private IMarkerDetector? MarkerDetector;
|
||||
private INavigation? Navigation;
|
||||
private IDetectSession? DetectSession;
|
||||
private string? StationId;
|
||||
private RobotDirection? Direction = null;
|
||||
|
||||
private const int MAX_TIMEOUT = 60000 * 5;
|
||||
private int CountTimeout = 0;
|
||||
private bool IsFindedGoal = false;
|
||||
private bool IsStartDockTo = false;
|
||||
private bool IsHasLoad = false;
|
||||
|
||||
protected override async Task StartAction()
|
||||
{
|
||||
MarkerDetector = ServiceProvider.GetRequiredService<IMarkerDetector>();
|
||||
Navigation = ServiceProvider.GetRequiredService<INavigation>();
|
||||
|
||||
if (!string.IsNullOrEmpty(StationId))
|
||||
{
|
||||
using var scope = ServiceProvider.CreateScope();
|
||||
var dbContext = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
|
||||
var config = await dbContext.DockStationConfigs
|
||||
.Include(d => d.MarkerEntries)
|
||||
.FirstOrDefaultAsync(d => d.StationId == StationId && d.IsActive);
|
||||
|
||||
if (config is not null)
|
||||
{
|
||||
var MarkersSearchRequest = new MarkersSearchRequest
|
||||
{
|
||||
X = config.X,
|
||||
Y = config.Y,
|
||||
Yaw = config.Yaw,
|
||||
Width = config.Width,
|
||||
Length = config.Length,
|
||||
MarkerSearchRequests = [.. config.MarkerEntries
|
||||
.OrderBy(m => m.Priority)
|
||||
.Select(m => new MarkerEntry
|
||||
{
|
||||
MarkerId = m.MarkerId ?? string.Empty,
|
||||
Type = (MarkerType)m.Type,
|
||||
Priority = m.Priority,
|
||||
DeviceId = m.DeviceId ?? string.Empty,
|
||||
Code = m.Code ?? string.Empty,
|
||||
ReferencePoints = DeserializeReferencePoints(m.ReferencePointsJson)
|
||||
})]
|
||||
};
|
||||
DetectSession = await MarkerDetector.CreateSessionAsync(MarkersSearchRequest);
|
||||
var plcController = scope.ServiceProvider.GetRequiredService<IPlcController>();
|
||||
IsHasLoad = plcController.SetHasLoadValue;
|
||||
//var StateMachine = scope.ServiceProvider.GetRequiredService<RobotStateMachine>();
|
||||
//StateMachine.Fire(RobotEventType.StartDocking);
|
||||
await base.StartAction();
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
SetStatus(ActionEvent.FAILED);
|
||||
ResultDescription = "Cannot get Marker Detection";
|
||||
}
|
||||
}
|
||||
|
||||
private static Vector2[] DeserializeReferencePoints(string? json)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(json)) return [];
|
||||
try { return JsonSerializer.Deserialize<Vector2[]>(json) ?? []; }
|
||||
catch { return []; }
|
||||
}
|
||||
|
||||
protected override Task StopAction()
|
||||
{
|
||||
Navigation?.CancelMovement();
|
||||
return base.StopAction();
|
||||
}
|
||||
|
||||
protected override Task CleanupAction()
|
||||
{
|
||||
DetectSession?.Dispose();
|
||||
DetectSession = null;
|
||||
MarkerDetector?.Dispose();
|
||||
MarkerDetector = null;
|
||||
//using var scope = ServiceProvider.CreateAsyncScope();
|
||||
//var StateMachine = scope.ServiceProvider.GetRequiredService<RobotStateMachine>();
|
||||
//StateMachine.Fire(RobotEventType.CompleteDocking);
|
||||
return base.CleanupAction();
|
||||
}
|
||||
|
||||
protected override Task ExecuteAction()
|
||||
{
|
||||
if (DetectSession is null)
|
||||
{
|
||||
SetStatus(ActionEvent.FAILED);
|
||||
ResultDescription = "Module Detect Marker is not existed";
|
||||
}
|
||||
else if (Navigation is null)
|
||||
{
|
||||
SetStatus(ActionEvent.FAILED);
|
||||
ResultDescription = "Module Navigation is not existed";
|
||||
}
|
||||
else
|
||||
{
|
||||
if (!IsFindedGoal) IsFindedGoal = DetectSession.Goal is not null;
|
||||
|
||||
if (CountTimeout++ > MAX_TIMEOUT / ActionInterval)
|
||||
{
|
||||
Navigation?.CancelMovement();
|
||||
SetStatus(ActionEvent.FAILED);
|
||||
ResultDescription = "Timeout action";
|
||||
}
|
||||
else if (!IsFindedGoal) base.ExecuteAction();
|
||||
else
|
||||
{
|
||||
if (!IsStartDockTo)
|
||||
{
|
||||
Navigation.DockTo(DetectSession, IsHasLoad, Direction);
|
||||
IsStartDockTo = true;
|
||||
}
|
||||
if (Navigation.State == NavigationState.Completed)
|
||||
{
|
||||
SetStatus(ActionEvent.FINISHED);
|
||||
ResultDescription = string.IsNullOrEmpty(ActionAttribute.ResultDescription) ? ResultDescription : ActionAttribute.ResultDescription;
|
||||
}
|
||||
else if (Navigation.State == NavigationState.Error)
|
||||
{
|
||||
SetStatus(ActionEvent.FAILED);
|
||||
ResultDescription = $"Action Handle [{Type} - {Id}] is failed: Navigation Failed";
|
||||
}
|
||||
}
|
||||
}
|
||||
return base.ExecuteAction();
|
||||
}
|
||||
|
||||
protected override void Initialize()
|
||||
{
|
||||
base.Initialize();
|
||||
|
||||
if (ActionParameters.Count > 0)
|
||||
{
|
||||
foreach (var parameterStore in ActionParameters)
|
||||
{
|
||||
if (!parameterStore.IsOptional)
|
||||
{
|
||||
if (Action is null || Action.ActionParameters is null || !Action.ActionParameters.Any(a => a.Key == parameterStore.Key)) throw new ActionException($"Thiếu tham số bắt buộc '{parameterStore.Key}' cho action {Type}.");
|
||||
}
|
||||
}
|
||||
Parameters = Action is null || Action.ActionParameters is null ? [] : Action.ActionParameters;
|
||||
}
|
||||
|
||||
var stationPara = Parameters.FirstOrDefault(p => p.Key == "stationId") ?? throw new ActionException($"Action {Type} không tìm thấy parameter key 'stationId'");
|
||||
StationId = stationPara.Value;
|
||||
var directionPara = Parameters.FirstOrDefault(p => p.Key == "direction");
|
||||
Direction = directionPara is not null && Enum.TryParse<RobotDirection>(directionPara.Value, out var direction) ? direction : null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
using RobotNet.VDA5050.Type;
|
||||
using RobotNet10.RobotApp.Client.Pages;
|
||||
using RobotNet10.RobotApp.Interfaces;
|
||||
using RobotNet10.RobotApp.Modules;
|
||||
|
||||
namespace RobotNet10.RobotApp.Services.Robot.Actions;
|
||||
|
||||
[RobotAction(ActionType.DROP,
|
||||
[ActionScope.INSTANT, ActionScope.NODE, ActionScope.EDGE],
|
||||
[BlockingType.HARD],
|
||||
"Hạ thấp pallet.",
|
||||
"Robot đã hạ thấp pallet.")]
|
||||
public class RobotDropAction(IServiceProvider ServiceProvider) : RobotAction(ServiceProvider)
|
||||
{
|
||||
private const int MAX_TIMEOUT = 60000;
|
||||
private int CountTimeout = 0;
|
||||
|
||||
private ILoad? LoadManager;
|
||||
private ILiftModule? LiftModule;
|
||||
private IPlcController? PlcController;
|
||||
private CancellationTokenSource CancellationToken = new();
|
||||
|
||||
protected override async Task StartAction()
|
||||
{
|
||||
try
|
||||
{
|
||||
LoadManager = ServiceProvider.GetRequiredService<ILoad>();
|
||||
LiftModule = ServiceProvider.GetRequiredService<ILiftModule>();
|
||||
PlcController = ServiceProvider.GetRequiredService<IPlcController>();
|
||||
|
||||
if (!LiftModule.IsReady)
|
||||
{
|
||||
SetStatus(ActionEvent.FAILED);
|
||||
ResultDescription = "Lift module not ready";
|
||||
return;
|
||||
}
|
||||
|
||||
CancellationToken = new CancellationTokenSource();
|
||||
PlcController.SetOperationState(OperationState.Lifting);
|
||||
await LiftModule.LiftDownAsync(CancellationToken.Token);
|
||||
CountTimeout = 0;
|
||||
await base.StartAction();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
SetStatus(ActionEvent.FAILED);
|
||||
ResultDescription = $"Drop Failed: {ex.Message}";
|
||||
}
|
||||
}
|
||||
|
||||
protected override Task StopAction()
|
||||
{
|
||||
CancellationToken.Cancel();
|
||||
return base.StopAction();
|
||||
}
|
||||
|
||||
protected override Task CleanupAction()
|
||||
{
|
||||
CancellationToken.Dispose();
|
||||
PlcController?.SetOperationState(OperationState.None);
|
||||
return base.CleanupAction();
|
||||
}
|
||||
|
||||
protected override async Task ExecuteAction()
|
||||
{
|
||||
if (LiftModule is null)
|
||||
{
|
||||
SetStatus(ActionEvent.FAILED);
|
||||
ResultDescription = "Lift module not found";
|
||||
}
|
||||
else if (LiftModule.State == LiftModuleState.Error)
|
||||
{
|
||||
SetStatus(ActionEvent.FAILED);
|
||||
ResultDescription = "Lift module error";
|
||||
}
|
||||
else if (LiftModule.Position == LiftPosition.Bottom)
|
||||
{
|
||||
LoadManager?.ClearLoad();
|
||||
SetStatus(ActionEvent.FINISHED);
|
||||
ResultDescription = string.IsNullOrEmpty(ActionAttribute.ResultDescription) ? "Robot has dropped the load." : ActionAttribute.ResultDescription;
|
||||
}
|
||||
else if (CountTimeout++ > MAX_TIMEOUT / ActionInterval)
|
||||
{
|
||||
CancellationToken.Cancel();
|
||||
SetStatus(ActionEvent.FAILED);
|
||||
ResultDescription = "Timeout action";
|
||||
}
|
||||
await base.ExecuteAction();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
using RobotNet.VDA5050.Type;
|
||||
using RobotNet10.RobotApp.Interfaces;
|
||||
|
||||
namespace RobotNet10.RobotApp.Services.Robot.Actions;
|
||||
|
||||
[RobotAction(ActionType.FACTSHEET_REQUEST,
|
||||
[ActionScope.INSTANT, ActionScope.NODE, ActionScope.EDGE],
|
||||
[BlockingType.NONE, BlockingType.SOFT, BlockingType.HARD],
|
||||
"Yêu cầu gửi Factsheet robot ngay lập tức.",
|
||||
"Robot đã gửi Factsheet ngay lập tức.")]
|
||||
public class RobotFactsheetRequestAction(IServiceProvider ServiceProvider) : RobotAction(ServiceProvider)
|
||||
{
|
||||
protected override async Task StartAction()
|
||||
{
|
||||
var RobotFactsheet = ServiceProvider.GetRequiredService<IFactsheet>();
|
||||
await RobotFactsheet.PubFactsheet();
|
||||
SetStatus(ActionEvent.FINISHED);
|
||||
ResultDescription = string.IsNullOrEmpty(ActionAttribute.ResultDescription) ? ResultDescription : ActionAttribute.ResultDescription;
|
||||
}
|
||||
|
||||
protected override Task ExecuteAction()
|
||||
{
|
||||
return base.ExecuteAction();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
using RobotNet.VDA5050.Factsheet;
|
||||
using RobotNet.VDA5050.Type;
|
||||
using RobotNet10.RobotApp.Interfaces;
|
||||
using RobotNet10.RobotApp.Services.Exceptions;
|
||||
using System.Collections.ObjectModel;
|
||||
|
||||
namespace RobotNet10.RobotApp.Services.Robot.Actions;
|
||||
|
||||
[RobotAction(ActionType.INIT_POSITION,
|
||||
[ActionScope.INSTANT, ActionScope.NODE],
|
||||
[BlockingType.HARD],
|
||||
"Khởi tạo lại vị trí robot.",
|
||||
"Robot đã khởi tạo lại vị trí.")]
|
||||
public class RobotInitPositionAction(IServiceProvider ServiceProvider) : RobotAction(ServiceProvider)
|
||||
{
|
||||
private static readonly ReadOnlyCollection<ActionParameter> ActionParameters = new List<ActionParameter>
|
||||
{
|
||||
new()
|
||||
{
|
||||
Key = "x",
|
||||
Description = "Tọa độ X của vị trí khởi tạo.",
|
||||
ValueDataType = ValueDataType.FLOAT,
|
||||
IsOptional = false,
|
||||
},
|
||||
new()
|
||||
{
|
||||
Key = "y",
|
||||
Description = "Tọa độ Y của vị trí khởi tạo.",
|
||||
ValueDataType = ValueDataType.FLOAT,
|
||||
IsOptional = false,
|
||||
},
|
||||
new()
|
||||
{
|
||||
Key = "theta",
|
||||
Description = "Góc quay (theta) của vị trí khởi tạo. (rad)",
|
||||
ValueDataType = ValueDataType.FLOAT,
|
||||
IsOptional = false,
|
||||
}
|
||||
}.AsReadOnly();
|
||||
|
||||
double X = 0;
|
||||
double Y = 0;
|
||||
double Theta = 0;
|
||||
ILocalization? Localization;
|
||||
|
||||
protected override Task StartAction()
|
||||
{
|
||||
Localization = ServiceProvider.GetRequiredService<ILocalization>();
|
||||
var initPose = Localization.SetInitializePosition(X, Y, Theta);
|
||||
if (!initPose.IsSuccess)
|
||||
{
|
||||
SetStatus(ActionEvent.FAILED);
|
||||
ResultDescription = initPose.Message;
|
||||
}
|
||||
else
|
||||
{
|
||||
SetStatus(ActionEvent.FINISHED);
|
||||
ResultDescription = string.IsNullOrEmpty(ActionAttribute.ResultDescription) ? ResultDescription : ActionAttribute.ResultDescription;
|
||||
}
|
||||
return base.StartAction();
|
||||
}
|
||||
|
||||
protected override void Initialize()
|
||||
{
|
||||
base.Initialize();
|
||||
|
||||
if (ActionParameters.Count > 0)
|
||||
{
|
||||
foreach (var parameterStore in ActionParameters)
|
||||
{
|
||||
if (!parameterStore.IsOptional)
|
||||
{
|
||||
if (Action is null || Action.ActionParameters is null || !Action.ActionParameters.Any(a => a.Key == parameterStore.Key)) throw new ActionException($"Thiếu tham số bắt buộc '{parameterStore.Key}' cho action {Type}.");
|
||||
}
|
||||
}
|
||||
Parameters = Action is null || Action.ActionParameters is null ? [] : Action.ActionParameters;
|
||||
}
|
||||
|
||||
var xPara = Parameters.FirstOrDefault(p => p.Key == "x") ?? throw new ActionException($"Action {Type} không tìm thấy parameter key 'x'");
|
||||
var yPara = Parameters.FirstOrDefault(p => p.Key == "y") ?? throw new ActionException($"Action {Type} không tìm thấy parameter key 'y'");
|
||||
var thetaPara = Parameters.FirstOrDefault(p => p.Key == "theta") ?? throw new ActionException($"Action {Type} không tìm thấy parameter key 'theta'");
|
||||
|
||||
var xParse = double.TryParse(xPara.Value, out double xData);
|
||||
var yParse = double.TryParse(yPara.Value, out double yData);
|
||||
var thetaParse = double.TryParse(thetaPara.Value, out double thetaData);
|
||||
|
||||
if (!xParse) throw new ActionException($"Action {Type} có parameter 'x' không đúng kiểu dữ liệu");
|
||||
if (!yParse) throw new ActionException($"Action {Type} có parameter 'y' không đúng kiểu dữ liệu");
|
||||
if (!thetaParse) throw new ActionException($"Action {Type} có parameter 'theta' không đúng kiểu dữ liệu");
|
||||
X = xData;
|
||||
Y = yData;
|
||||
Theta = thetaData;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
using RobotNet.VDA5050.Factsheet;
|
||||
using RobotNet.VDA5050.Type;
|
||||
using RobotNet10.RobotApp.Interfaces;
|
||||
using RobotNet10.RobotApp.Modules;
|
||||
using RobotNet10.RobotApp.Services.Exceptions;
|
||||
|
||||
namespace RobotNet10.RobotApp.Services.Robot.Actions;
|
||||
|
||||
[RobotAction(ActionType.LIFT_ROTATE,
|
||||
[ActionScope.INSTANT, ActionScope.NODE, ActionScope.EDGE],
|
||||
[BlockingType.HARD],
|
||||
"Xoay bàn nâng của robot.",
|
||||
"Robot đã xoay bàn nâng.")]
|
||||
public class RobotLiftRotateAction(IServiceProvider ServiceProvider) : RobotAction(ServiceProvider)
|
||||
{
|
||||
private static readonly IReadOnlyList<ActionParameter> ActionParameters = new List<ActionParameter>
|
||||
{
|
||||
new()
|
||||
{
|
||||
Key = "angle",
|
||||
Description = "Góc xoay của bàn nâng. (rad)",
|
||||
ValueDataType = ValueDataType.FLOAT,
|
||||
IsOptional = false,
|
||||
}
|
||||
}.AsReadOnly();
|
||||
private double Angle = 0; // Degree
|
||||
private IRotationModule? RotationModule;
|
||||
private IPlcController? PlcController;
|
||||
|
||||
private const int MAX_TIMEOUT = 60000 * 2;
|
||||
private int CountTimeout = 0;
|
||||
|
||||
protected override Task StartAction()
|
||||
{
|
||||
RotationModule = ServiceProvider.GetRequiredService<IRotationModule>();
|
||||
PlcController = ServiceProvider.GetRequiredService<IPlcController>();
|
||||
PlcController.SetOperationState(OperationState.LiftRotating);
|
||||
RotationModule.RotateToAngleAsync(Angle);
|
||||
return base.StartAction();
|
||||
}
|
||||
|
||||
protected override Task CleanupAction()
|
||||
{
|
||||
PlcController?.SetOperationState(OperationState.None);
|
||||
return base.CleanupAction();
|
||||
}
|
||||
|
||||
protected override async Task ExecuteAction()
|
||||
{
|
||||
if (RotationModule is null)
|
||||
{
|
||||
SetStatus(ActionEvent.FAILED);
|
||||
ResultDescription = "Không tìm thấy mô-đun xoay.";
|
||||
}
|
||||
else if (RotationModule.State == RotationModuleState.Error)
|
||||
{
|
||||
SetStatus(ActionEvent.FAILED);
|
||||
ResultDescription = "Mô-đun xoay gặp lỗi.";
|
||||
|
||||
}
|
||||
else if (RotationModule.State == RotationModuleState.Ready && Math.Abs(await RotationModule.GetCurrentAngleAsync() - Angle) < 1)
|
||||
{
|
||||
SetStatus(ActionEvent.FINISHED);
|
||||
ResultDescription = string.IsNullOrEmpty(ActionAttribute.ResultDescription) ? ResultDescription : ActionAttribute.ResultDescription;
|
||||
}
|
||||
else if (CountTimeout++ >= MAX_TIMEOUT / ActionInterval)
|
||||
{
|
||||
SetStatus(ActionEvent.FAILED);
|
||||
ResultDescription = "Timeout action";
|
||||
}
|
||||
await base.ExecuteAction();
|
||||
}
|
||||
|
||||
protected override void Initialize()
|
||||
{
|
||||
base.Initialize();
|
||||
|
||||
if (ActionParameters.Count > 0)
|
||||
{
|
||||
foreach (var parameterStore in ActionParameters)
|
||||
{
|
||||
if (!parameterStore.IsOptional)
|
||||
{
|
||||
if (Action is null || Action.ActionParameters is null || !Action.ActionParameters.Any(a => a.Key == parameterStore.Key)) throw new ActionException($"Thiếu tham số bắt buộc '{parameterStore.Key}' cho action {Type}.");
|
||||
}
|
||||
}
|
||||
Parameters = Action is null || Action.ActionParameters is null ? [] : Action.ActionParameters;
|
||||
}
|
||||
|
||||
var anglePara = Parameters.FirstOrDefault(p => p.Key == "angle") ?? throw new ActionException($"Action {Type} không tìm thấy parameter key 'angle'");
|
||||
var angleParse = double.TryParse(anglePara.Value, out double angleData);
|
||||
if (!angleParse) throw new ActionException($"Action {Type} có parameter 'angle' không đúng kiểu dữ liệu");
|
||||
Angle = angleData * 180 / Math.PI;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
using RobotNet.VDA5050.Factsheet;
|
||||
using RobotNet.VDA5050.Type;
|
||||
using RobotNet10.RobotApp.Interfaces;
|
||||
using RobotNet10.RobotApp.Services.Exceptions;
|
||||
using RobotNet10.RobotApp.Shared.Enums;
|
||||
using System.Collections.ObjectModel;
|
||||
|
||||
namespace RobotNet10.RobotApp.Services.Robot.Actions;
|
||||
|
||||
[RobotAction(ActionType.MOVE_STRAIGHT_TO_COOR,
|
||||
[ActionScope.INSTANT],
|
||||
[BlockingType.HARD],
|
||||
"Di chuyển thẳng đến tọa độ xác định.",
|
||||
"Robot đã di chuyển thẳng đến tọa độ xác định.")]
|
||||
public class RobotMoveStraightToCoorAction(IServiceProvider ServiceProvider) : RobotAction(ServiceProvider)
|
||||
{
|
||||
private static readonly ReadOnlyCollection<ActionParameter> ActionParameters = new List<ActionParameter>
|
||||
{
|
||||
new()
|
||||
{
|
||||
Key = "x",
|
||||
Description = "Tọa độ X đích đến.",
|
||||
ValueDataType = ValueDataType.FLOAT,
|
||||
IsOptional = false,
|
||||
},
|
||||
new()
|
||||
{
|
||||
Key = "y",
|
||||
Description = "Tọa độ Y đích đến.",
|
||||
ValueDataType = ValueDataType.FLOAT,
|
||||
IsOptional = false,
|
||||
},
|
||||
new()
|
||||
{
|
||||
Key = "direction",
|
||||
Description = "Hướng di chuyển: FORWARD, BACKWARD",
|
||||
ValueDataType = ValueDataType.STRING,
|
||||
IsOptional = true,
|
||||
},
|
||||
}.AsReadOnly();
|
||||
|
||||
private INavigation? Navigation;
|
||||
private double TargetX;
|
||||
private double TargetY;
|
||||
private RobotDirection? Direction = null;
|
||||
|
||||
private const int MAX_TIMEOUT = 60000 * 5;
|
||||
private int CountTimeout = 0;
|
||||
private bool IsStartMoveStraight = false;
|
||||
private bool IsHasLoad = false;
|
||||
|
||||
protected override Task StartAction()
|
||||
{
|
||||
Navigation = ServiceProvider.GetRequiredService<INavigation>();
|
||||
using var scope = ServiceProvider.CreateScope();
|
||||
var plcController = scope.ServiceProvider.GetRequiredService<IPlcController>();
|
||||
IsHasLoad = plcController.SetHasLoadValue;
|
||||
return base.StartAction();
|
||||
}
|
||||
|
||||
protected override Task StopAction()
|
||||
{
|
||||
Navigation?.CancelMovement();
|
||||
return base.StopAction();
|
||||
}
|
||||
|
||||
protected override Task ExecuteAction()
|
||||
{
|
||||
if (Navigation is null)
|
||||
{
|
||||
SetStatus(ActionEvent.FAILED);
|
||||
ResultDescription = "Module Navigation is not existed";
|
||||
}
|
||||
else
|
||||
{
|
||||
if (CountTimeout++ > MAX_TIMEOUT / ActionInterval)
|
||||
{
|
||||
Navigation?.CancelMovement();
|
||||
SetStatus(ActionEvent.FAILED);
|
||||
ResultDescription = "Timeout action";
|
||||
}
|
||||
else
|
||||
{
|
||||
if (!IsStartMoveStraight)
|
||||
{
|
||||
Navigation.MoveStraight(TargetX, TargetY, IsHasLoad, Direction);
|
||||
IsStartMoveStraight = true;
|
||||
}
|
||||
if (Navigation.State == NavigationState.Completed)
|
||||
{
|
||||
SetStatus(ActionEvent.FINISHED);
|
||||
ResultDescription = string.IsNullOrEmpty(ActionAttribute.ResultDescription) ? ResultDescription : ActionAttribute.ResultDescription;
|
||||
}
|
||||
else if (Navigation.State == NavigationState.Error)
|
||||
{
|
||||
SetStatus(ActionEvent.FAILED);
|
||||
ResultDescription = $"Action Handle [{Type} - {Id}] is failed: Navigation Failed";
|
||||
}
|
||||
}
|
||||
}
|
||||
return base.ExecuteAction();
|
||||
}
|
||||
|
||||
protected override void Initialize()
|
||||
{
|
||||
base.Initialize();
|
||||
|
||||
if (ActionParameters.Count > 0)
|
||||
{
|
||||
foreach (var parameterStore in ActionParameters)
|
||||
{
|
||||
if (!parameterStore.IsOptional)
|
||||
{
|
||||
if (Action is null || Action.ActionParameters is null || !Action.ActionParameters.Any(a => a.Key == parameterStore.Key))
|
||||
throw new ActionException($"Thiếu tham số bắt buộc '{parameterStore.Key}' cho action {Type}.");
|
||||
}
|
||||
}
|
||||
Parameters = Action is null || Action.ActionParameters is null ? [] : Action.ActionParameters;
|
||||
}
|
||||
|
||||
var xPara = Parameters.FirstOrDefault(p => p.Key == "x") ?? throw new ActionException($"Action {Type} không tìm thấy parameter key 'x'");
|
||||
var yPara = Parameters.FirstOrDefault(p => p.Key == "y") ?? throw new ActionException($"Action {Type} không tìm thấy parameter key 'y'");
|
||||
TargetX = double.Parse(xPara.Value);
|
||||
TargetY = double.Parse(yPara.Value);
|
||||
var directionPara = Parameters.FirstOrDefault(p => p.Key == "direction");
|
||||
Direction = directionPara is not null && Enum.TryParse<RobotDirection>(directionPara.Value, out var direction) ? direction : null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
using RobotNet.VDA5050.Factsheet;
|
||||
using RobotNet.VDA5050.Type;
|
||||
using RobotNet10.RobotApp.Interfaces;
|
||||
using RobotNet10.RobotApp.Services.Exceptions;
|
||||
using RobotNet10.RobotApp.Shared.Enums;
|
||||
using System.Collections.ObjectModel;
|
||||
|
||||
namespace RobotNet10.RobotApp.Services.Robot.Actions;
|
||||
|
||||
[RobotAction(ActionType.MOVE_STRAIGHT_WITH_DISTANCE,
|
||||
[ActionScope.INSTANT],
|
||||
[BlockingType.HARD],
|
||||
"Di chuyển thẳng với khoảng cách xác định.",
|
||||
"Robot đã di chuyển thẳng với khoảng cách xác định.")]
|
||||
public class RobotMoveStraightWithDistanceAction(IServiceProvider ServiceProvider) : RobotAction(ServiceProvider)
|
||||
{
|
||||
private static readonly ReadOnlyCollection<ActionParameter> ActionParameters = new List<ActionParameter>
|
||||
{
|
||||
new()
|
||||
{
|
||||
Key = "distance",
|
||||
Description = "Khoảng cách di chuyển. (m)",
|
||||
ValueDataType = ValueDataType.FLOAT,
|
||||
IsOptional = false,
|
||||
},
|
||||
new()
|
||||
{
|
||||
Key = "direction",
|
||||
Description = "Hướng di chuyển: FORWARD, BACKWARD",
|
||||
ValueDataType = ValueDataType.STRING,
|
||||
IsOptional = true,
|
||||
},
|
||||
new()
|
||||
{
|
||||
Key = "angle",
|
||||
Description = "Góc di chuyển so với hướng hiện tại của robot. (rad)",
|
||||
ValueDataType = ValueDataType.FLOAT,
|
||||
IsOptional = false,
|
||||
}
|
||||
}.AsReadOnly();
|
||||
|
||||
private INavigation? Navigation;
|
||||
private ILocalization? Localization;
|
||||
private double Distance;
|
||||
private double Angle;
|
||||
private RobotDirection? Direction = null;
|
||||
|
||||
private const int MAX_TIMEOUT = 60000 * 5;
|
||||
private int CountTimeout = 0;
|
||||
private bool IsStartMoveStraight = false;
|
||||
private bool IsHasLoad = false;
|
||||
|
||||
protected override Task StartAction()
|
||||
{
|
||||
Navigation = ServiceProvider.GetRequiredService<INavigation>();
|
||||
Localization = ServiceProvider.GetRequiredService<ILocalization>();
|
||||
using var scope = ServiceProvider.CreateScope();
|
||||
var plcController = scope.ServiceProvider.GetRequiredService<IPlcController>();
|
||||
IsHasLoad = plcController.SetHasLoadValue;
|
||||
return base.StartAction();
|
||||
}
|
||||
|
||||
protected override Task StopAction()
|
||||
{
|
||||
Navigation?.CancelMovement();
|
||||
return base.StopAction();
|
||||
}
|
||||
|
||||
protected override Task ExecuteAction()
|
||||
{
|
||||
if (Navigation is null || Localization is null)
|
||||
{
|
||||
SetStatus(ActionEvent.FAILED);
|
||||
ResultDescription = "Module Navigation or Localization is not existed";
|
||||
}
|
||||
else
|
||||
{
|
||||
if (CountTimeout++ > MAX_TIMEOUT / ActionInterval)
|
||||
{
|
||||
Navigation?.CancelMovement();
|
||||
SetStatus(ActionEvent.FAILED);
|
||||
ResultDescription = "Timeout action";
|
||||
}
|
||||
else
|
||||
{
|
||||
if (!IsStartMoveStraight)
|
||||
{
|
||||
double targetX = Localization.X + Distance * Math.Cos(Angle);
|
||||
double targetY = Localization.Y + Distance * Math.Sin(Angle);
|
||||
Navigation.MoveStraight(targetX, targetY, IsHasLoad, Direction);
|
||||
IsStartMoveStraight = true;
|
||||
}
|
||||
if (Navigation.State == NavigationState.Completed)
|
||||
{
|
||||
SetStatus(ActionEvent.FINISHED);
|
||||
ResultDescription = string.IsNullOrEmpty(ActionAttribute.ResultDescription) ? ResultDescription : ActionAttribute.ResultDescription;
|
||||
}
|
||||
else if (Navigation.State == NavigationState.Error)
|
||||
{
|
||||
SetStatus(ActionEvent.FAILED);
|
||||
ResultDescription = $"Action Handle [{Type} - {Id}] is failed: Navigation Failed";
|
||||
}
|
||||
}
|
||||
}
|
||||
return base.ExecuteAction();
|
||||
}
|
||||
|
||||
protected override void Initialize()
|
||||
{
|
||||
base.Initialize();
|
||||
|
||||
if (ActionParameters.Count > 0)
|
||||
{
|
||||
foreach (var parameterStore in ActionParameters)
|
||||
{
|
||||
if (!parameterStore.IsOptional)
|
||||
{
|
||||
if (Action is null || Action.ActionParameters is null || !Action.ActionParameters.Any(a => a.Key == parameterStore.Key))
|
||||
throw new ActionException($"Thiếu tham số bắt buộc '{parameterStore.Key}' cho action {Type}.");
|
||||
}
|
||||
}
|
||||
Parameters = Action is null || Action.ActionParameters is null ? [] : Action.ActionParameters;
|
||||
}
|
||||
|
||||
var distancePara = Parameters.FirstOrDefault(p => p.Key == "distance") ?? throw new ActionException($"Action {Type} không tìm thấy parameter key 'distance'");
|
||||
Distance = double.Parse(distancePara.Value);
|
||||
var anglePara = Parameters.FirstOrDefault(p => p.Key == "angle") ?? throw new ActionException($"Action {Type} không tìm thấy parameter key 'angle'");
|
||||
Angle = double.Parse(anglePara.Value);
|
||||
var directionPara = Parameters.FirstOrDefault(p => p.Key == "direction");
|
||||
Direction = directionPara is not null && Enum.TryParse<RobotDirection>(directionPara.Value, out var direction) ? direction : null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
using RobotNet.VDA5050.Type;
|
||||
using RobotNet10.RobotApp.Interfaces;
|
||||
|
||||
namespace RobotNet10.RobotApp.Services.Robot.Actions;
|
||||
|
||||
|
||||
[RobotAction(ActionType.MUTED_BASE_OFF,
|
||||
[ActionScope.INSTANT, ActionScope.NODE, ActionScope.EDGE],
|
||||
[BlockingType.NONE, BlockingType.SOFT, BlockingType.HARD],
|
||||
"Tắt chế độ muted base robot.",
|
||||
"Robot đã tắt chế độ muted base.")]
|
||||
public class RobotMutedBaseOffAction(IServiceProvider ServiceProvider) : RobotAction(ServiceProvider)
|
||||
{
|
||||
private IPlcController? PlcController;
|
||||
|
||||
private const int MAX_TIMEOUT = 4000;
|
||||
private int CountTimeout = 0;
|
||||
protected override Task StartAction()
|
||||
{
|
||||
PlcController = ServiceProvider.GetRequiredService<IPlcController>();
|
||||
PlcController.SetMutedBase(false);
|
||||
return base.StartAction();
|
||||
}
|
||||
|
||||
protected override Task ExecuteAction()
|
||||
{
|
||||
if (PlcController is not null && !PlcController.MutedBase)
|
||||
{
|
||||
SetStatus(ActionEvent.FINISHED);
|
||||
ResultDescription = string.IsNullOrEmpty(ActionAttribute.ResultDescription) ? ResultDescription : ActionAttribute.ResultDescription;
|
||||
}
|
||||
else if (CountTimeout++ > MAX_TIMEOUT / ActionInterval)
|
||||
{
|
||||
SetStatus(ActionEvent.FAILED);
|
||||
ResultDescription = "Timeout action";
|
||||
}
|
||||
return base.ExecuteAction();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
using RobotNet.VDA5050.Type;
|
||||
using RobotNet10.RobotApp.Interfaces;
|
||||
|
||||
namespace RobotNet10.RobotApp.Services.Robot.Actions;
|
||||
|
||||
[RobotAction(ActionType.MUTED_BASE_ON,
|
||||
[ActionScope.INSTANT, ActionScope.NODE, ActionScope.EDGE],
|
||||
[BlockingType.NONE, BlockingType.SOFT, BlockingType.HARD],
|
||||
"Bật chế độ muted base robot.",
|
||||
"Robot đã bật chế độ muted base.")]
|
||||
public class RobotMutedBaseOnAction(IServiceProvider ServiceProvider) : RobotAction(ServiceProvider)
|
||||
{
|
||||
private IPlcController? PlcController;
|
||||
|
||||
private const int MAX_TIMEOUT = 4000;
|
||||
private int CountTimeout = 0;
|
||||
protected override Task StartAction()
|
||||
{
|
||||
PlcController = ServiceProvider.GetRequiredService<IPlcController>();
|
||||
PlcController.SetMutedBase(true);
|
||||
return base.StartAction();
|
||||
}
|
||||
|
||||
protected override Task ExecuteAction()
|
||||
{
|
||||
if(PlcController is not null && PlcController.MutedBase)
|
||||
{
|
||||
SetStatus(ActionEvent.FINISHED);
|
||||
ResultDescription = string.IsNullOrEmpty(ActionAttribute.ResultDescription) ? ResultDescription : ActionAttribute.ResultDescription;
|
||||
}
|
||||
else if (CountTimeout++ > MAX_TIMEOUT / ActionInterval)
|
||||
{
|
||||
SetStatus(ActionEvent.FAILED);
|
||||
ResultDescription = "Timeout action";
|
||||
}
|
||||
return base.ExecuteAction();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
using RobotNet.VDA5050.Type;
|
||||
using RobotNet10.RobotApp.Interfaces;
|
||||
|
||||
namespace RobotNet10.RobotApp.Services.Robot.Actions;
|
||||
|
||||
[RobotAction(ActionType.MUTED_LOAD_OFF,
|
||||
[ActionScope.INSTANT, ActionScope.NODE, ActionScope.EDGE],
|
||||
[BlockingType.NONE, BlockingType.SOFT, BlockingType.HARD],
|
||||
"Tắt chế độ muted load robot.",
|
||||
"Robot đã tắt chế độ muted load.")]
|
||||
public class RobotMutedLoadOffAction(IServiceProvider ServiceProvider) : RobotAction(ServiceProvider)
|
||||
{
|
||||
private IPlcController? PlcController;
|
||||
private const int MAX_TIMEOUT = 4000;
|
||||
private int CountTimeout = 0;
|
||||
protected override Task StartAction()
|
||||
{
|
||||
PlcController = ServiceProvider.GetRequiredService<IPlcController>();
|
||||
PlcController.SetMutedLoad(false);
|
||||
return base.StartAction();
|
||||
}
|
||||
|
||||
protected override Task ExecuteAction()
|
||||
{
|
||||
if (PlcController is not null && !PlcController.MutedLoad)
|
||||
{
|
||||
SetStatus(ActionEvent.FINISHED);
|
||||
ResultDescription = string.IsNullOrEmpty(ActionAttribute.ResultDescription) ? ResultDescription : ActionAttribute.ResultDescription;
|
||||
}
|
||||
else if (CountTimeout++ > MAX_TIMEOUT / ActionInterval)
|
||||
{
|
||||
SetStatus(ActionEvent.FAILED);
|
||||
ResultDescription = "Timeout action";
|
||||
}
|
||||
return base.ExecuteAction();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
using RobotNet.VDA5050.Type;
|
||||
using RobotNet10.RobotApp.Interfaces;
|
||||
|
||||
namespace RobotNet10.RobotApp.Services.Robot.Actions;
|
||||
|
||||
[RobotAction(ActionType.MUTED_LOAD_ON,
|
||||
[ActionScope.INSTANT, ActionScope.NODE, ActionScope.EDGE],
|
||||
[BlockingType.NONE, BlockingType.SOFT, BlockingType.HARD],
|
||||
"Bật chế độ muted load robot.",
|
||||
"Robot đã bật chế độ muted load.")]
|
||||
public class RobotMutedLoadOnAction(IServiceProvider ServiceProvider) : RobotAction(ServiceProvider)
|
||||
{
|
||||
private IPlcController? PlcController;
|
||||
|
||||
private const int MAX_TIMEOUT = 4000;
|
||||
private int CountTimeout = 0;
|
||||
protected override Task StartAction()
|
||||
{
|
||||
PlcController = ServiceProvider.GetRequiredService<IPlcController>();
|
||||
PlcController.SetMutedLoad(true);
|
||||
return base.StartAction();
|
||||
}
|
||||
|
||||
protected override Task ExecuteAction()
|
||||
{
|
||||
if (PlcController is not null && PlcController.MutedLoad)
|
||||
{
|
||||
SetStatus(ActionEvent.FINISHED);
|
||||
ResultDescription = string.IsNullOrEmpty(ActionAttribute.ResultDescription) ? ResultDescription : ActionAttribute.ResultDescription;
|
||||
}
|
||||
else if (CountTimeout++ > MAX_TIMEOUT / ActionInterval)
|
||||
{
|
||||
SetStatus(ActionEvent.FAILED);
|
||||
ResultDescription = "Timeout action";
|
||||
}
|
||||
return base.ExecuteAction();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
using NLog;
|
||||
using RobotNet.VDA5050.Type;
|
||||
using RobotNet10.RobotApp.Client.Pages;
|
||||
using RobotNet10.RobotApp.Interfaces;
|
||||
using RobotNet10.RobotApp.Modules;
|
||||
|
||||
namespace RobotNet10.RobotApp.Services.Robot.Actions;
|
||||
|
||||
[RobotAction(ActionType.PICK,
|
||||
[ActionScope.INSTANT, ActionScope.NODE, ActionScope.EDGE],
|
||||
[BlockingType.HARD],
|
||||
"Nâng cao pallet.",
|
||||
"Robot đã nâng cao pallet.")]
|
||||
public class RobotPickAction(IServiceProvider ServiceProvider) : RobotAction(ServiceProvider)
|
||||
{
|
||||
private const int MAX_TIMEOUT = 60000;
|
||||
private int CountTimeout = 0;
|
||||
private ILoad? LoadManager;
|
||||
private ILiftModule? LiftModule;
|
||||
private IPlcController? PlcController;
|
||||
private CancellationTokenSource CancellationToken = new();
|
||||
protected override async Task StartAction()
|
||||
{
|
||||
try
|
||||
{
|
||||
LoadManager = ServiceProvider.GetRequiredService<ILoad>();
|
||||
LiftModule = ServiceProvider.GetRequiredService<ILiftModule>();
|
||||
PlcController = ServiceProvider.GetRequiredService<IPlcController>();
|
||||
|
||||
if (!LiftModule.IsReady)
|
||||
{
|
||||
SetStatus(ActionEvent.FAILED);
|
||||
ResultDescription = "Lift module not ready";
|
||||
return;
|
||||
}
|
||||
|
||||
CancellationToken = new CancellationTokenSource();
|
||||
PlcController.SetOperationState(OperationState.Lifting);
|
||||
_ = LiftModule.LiftUpAsync(CancellationToken.Token);
|
||||
CountTimeout = 0;
|
||||
await base.StartAction();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
SetStatus(ActionEvent.FAILED);
|
||||
ResultDescription = "Pick failed: " + ex.Message;
|
||||
}
|
||||
}
|
||||
|
||||
protected override Task StopAction()
|
||||
{
|
||||
CancellationToken.Cancel();
|
||||
return base.StopAction();
|
||||
}
|
||||
|
||||
protected override Task CleanupAction()
|
||||
{
|
||||
CancellationToken.Dispose();
|
||||
PlcController?.SetOperationState(OperationState.None);
|
||||
return base.CleanupAction();
|
||||
}
|
||||
|
||||
protected override async Task ExecuteAction()
|
||||
{
|
||||
if (LiftModule is null)
|
||||
{
|
||||
SetStatus(ActionEvent.FAILED);
|
||||
ResultDescription = "Lift module not found";
|
||||
}
|
||||
else if (LiftModule.State == LiftModuleState.Error)
|
||||
{
|
||||
SetStatus(ActionEvent.FAILED);
|
||||
ResultDescription = "Lift module error";
|
||||
}
|
||||
else if (LiftModule.Position == LiftPosition.Top)
|
||||
{
|
||||
LoadManager?.AddLoad(new());
|
||||
SetStatus(ActionEvent.FINISHED);
|
||||
ResultDescription = string.IsNullOrEmpty(ActionAttribute.ResultDescription) ? "Robot has picked up the load." : ActionAttribute.ResultDescription;
|
||||
}
|
||||
else if (CountTimeout++ > MAX_TIMEOUT / ActionInterval)
|
||||
{
|
||||
CancellationToken.Cancel();
|
||||
SetStatus(ActionEvent.FAILED);
|
||||
ResultDescription = "Timeout action";
|
||||
}
|
||||
await base.ExecuteAction();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
using MudBlazor.Extensions;
|
||||
using RobotNet.VDA5050.Factsheet;
|
||||
using RobotNet.VDA5050.Type;
|
||||
using RobotNet10.RobotApp.Interfaces;
|
||||
using RobotNet10.RobotApp.Services.Exceptions;
|
||||
using System.Collections.ObjectModel;
|
||||
|
||||
namespace RobotNet10.RobotApp.Services.Robot.Actions;
|
||||
|
||||
[RobotAction(ActionType.ROTATE,
|
||||
[ActionScope.INSTANT, ActionScope.NODE, ActionScope.EDGE],
|
||||
[BlockingType.HARD],
|
||||
"Xoay robot tại chỗ.",
|
||||
"Robot đã xoay tại chỗ.")]
|
||||
public class RobotRotateAction(IServiceProvider ServiceProvider) : RobotAction(ServiceProvider)
|
||||
{
|
||||
private static readonly ReadOnlyCollection<ActionParameter> ActionParameters = new List<ActionParameter>
|
||||
{
|
||||
new()
|
||||
{
|
||||
Key = "angle",
|
||||
Description = "Góc xoay của robot. (rad)",
|
||||
ValueDataType = ValueDataType.FLOAT,
|
||||
IsOptional = false,
|
||||
}
|
||||
}.AsReadOnly();
|
||||
|
||||
private double Angle = 0;
|
||||
private INavigation? RobotNavigation;
|
||||
private ILocalization? Localization;
|
||||
|
||||
private const int MAX_TIMEOUT = 60000 * 2;
|
||||
private int CountTimeout = 0;
|
||||
protected override Task StartAction()
|
||||
{
|
||||
RobotNavigation = ServiceProvider.GetRequiredService<INavigation>();
|
||||
Localization = ServiceProvider.GetRequiredService<ILocalization>();
|
||||
RobotNavigation.Rotate(Angle);
|
||||
CountTimeout = 0;
|
||||
return base.StartAction();
|
||||
}
|
||||
|
||||
protected override Task StopAction()
|
||||
{
|
||||
RobotNavigation?.CancelMovement();
|
||||
return base.StopAction();
|
||||
}
|
||||
|
||||
protected override Task ExecuteAction()
|
||||
{
|
||||
if (RobotNavigation is null)
|
||||
{
|
||||
SetStatus(ActionEvent.FAILED);
|
||||
ResultDescription = "Module Navigation is not existed";
|
||||
}
|
||||
else if (RobotNavigation.State == NavigationState.Completed && Localization is not null && Math.Abs(Localization.Theta - Angle) * 180 / Math.PI < 5)
|
||||
{
|
||||
SetStatus(ActionEvent.FINISHED);
|
||||
ResultDescription = string.IsNullOrEmpty(ActionAttribute.ResultDescription) ? ResultDescription : ActionAttribute.ResultDescription;
|
||||
}
|
||||
else if (RobotNavigation.State == NavigationState.Error)
|
||||
{
|
||||
SetStatus(ActionEvent.FAILED);
|
||||
ResultDescription = $"Action Handle [{Type} - {Id}] - angle: {Angle} is failed";
|
||||
}
|
||||
else if (CountTimeout++ > MAX_TIMEOUT / ActionInterval)
|
||||
{
|
||||
RobotNavigation?.CancelMovement();
|
||||
SetStatus(ActionEvent.FAILED);
|
||||
ResultDescription = "Timeout action";
|
||||
}
|
||||
return base.ExecuteAction();
|
||||
}
|
||||
|
||||
protected override void Initialize()
|
||||
{
|
||||
base.Initialize();
|
||||
|
||||
if (ActionParameters.Count > 0)
|
||||
{
|
||||
foreach (var parameterStore in ActionParameters)
|
||||
{
|
||||
if (!parameterStore.IsOptional)
|
||||
{
|
||||
if (Action is null || Action.ActionParameters is null || !Action.ActionParameters.Any(a => a.Key == parameterStore.Key)) throw new ActionException($"Thiếu tham số bắt buộc '{parameterStore.Key}' cho action {Type}.");
|
||||
}
|
||||
}
|
||||
Parameters = Action is null || Action.ActionParameters is null ? [] : Action.ActionParameters;
|
||||
}
|
||||
|
||||
var anglePara = Parameters.FirstOrDefault(p => p.Key == "angle") ?? throw new ActionException($"Action {Type} không tìm thấy parameter key 'angle'");
|
||||
var angleParse = double.TryParse(anglePara.Value, out double angleData);
|
||||
if (!angleParse) throw new ActionException($"Action {Type} có parameter 'angle' không đúng kiểu dữ liệu");
|
||||
Angle = angleData;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
using RobotNet.VDA5050.Factsheet;
|
||||
using RobotNet.VDA5050.Type;
|
||||
using RobotNet10.RobotApp.Services.Exceptions;
|
||||
using System.Collections.ObjectModel;
|
||||
|
||||
namespace RobotNet10.RobotApp.Services.Robot.Actions;
|
||||
|
||||
[RobotAction(ActionType.ROTATE_KEEP_LIFT,
|
||||
[ActionScope.INSTANT, ActionScope.NODE, ActionScope.EDGE],
|
||||
[BlockingType.HARD],
|
||||
"Xoay robot tại chỗ giữ nguyên trạng thái bàn nâng.",
|
||||
"Robot đã xoay tại chỗ giữ nguyên trạng thái bàn nâng.")]
|
||||
public class RobotRotateKeepLift(IServiceProvider ServiceProvider) : RobotAction(ServiceProvider)
|
||||
{
|
||||
private static readonly ReadOnlyCollection<ActionParameter> ActionParameters = new List<ActionParameter>
|
||||
{
|
||||
new()
|
||||
{
|
||||
Key = "angle",
|
||||
Description = "Góc xoay của robot. (rad)",
|
||||
ValueDataType = ValueDataType.FLOAT,
|
||||
IsOptional = false,
|
||||
}
|
||||
}.AsReadOnly();
|
||||
double Angle = 0;
|
||||
protected override Task StartAction()
|
||||
{
|
||||
SetStatus(ActionEvent.FINISHED);
|
||||
ResultDescription = string.IsNullOrEmpty(ActionAttribute.ResultDescription) ? ResultDescription : ActionAttribute.ResultDescription;
|
||||
return base.StartAction();
|
||||
}
|
||||
|
||||
protected override Task ExecuteAction()
|
||||
{
|
||||
return base.ExecuteAction();
|
||||
}
|
||||
|
||||
protected override void Initialize()
|
||||
{
|
||||
base.Initialize();
|
||||
|
||||
if (ActionParameters.Count > 0)
|
||||
{
|
||||
foreach (var parameterStore in ActionParameters)
|
||||
{
|
||||
if (!parameterStore.IsOptional)
|
||||
{
|
||||
if (Action is null || Action.ActionParameters is null || !Action.ActionParameters.Any(a => a.Key == parameterStore.Key)) throw new ActionException($"Thiếu tham số bắt buộc '{parameterStore.Key}' cho action {Type}.");
|
||||
}
|
||||
}
|
||||
Parameters = Action is null || Action.ActionParameters is null ? [] : Action.ActionParameters;
|
||||
}
|
||||
|
||||
var anglePara = Parameters.FirstOrDefault(p => p.Key == "angle") ?? throw new ActionException($"Action {Type} không tìm thấy parameter key 'angle'");
|
||||
var angleParse = double.TryParse(anglePara.Value, out double angleData);
|
||||
if (!angleParse) throw new ActionException($"Action {Type} có parameter 'angle' không đúng kiểu dữ liệu");
|
||||
Angle = angleData;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
using RobotNet.VDA5050.Factsheet;
|
||||
using RobotNet.VDA5050.Type;
|
||||
using RobotNet10.RobotApp.Services.Exceptions;
|
||||
using System.Collections.ObjectModel;
|
||||
|
||||
namespace RobotNet10.RobotApp.Services.Robot.Actions;
|
||||
|
||||
[RobotAction(ActionType.SCRIPT,
|
||||
[ActionScope.INSTANT, ActionScope.NODE, ActionScope.EDGE],
|
||||
[BlockingType.NONE, BlockingType.SOFT, BlockingType.HARD],
|
||||
"This is an script robot action.",
|
||||
"Script robot action completed.")]
|
||||
public class RobotScriptAction(IServiceProvider serviceProvider) : RobotAction(serviceProvider)
|
||||
{
|
||||
private static readonly ReadOnlyCollection<ActionParameter> ActionParameters = new List<ActionParameter>
|
||||
{
|
||||
new() {
|
||||
Key = "missionName",
|
||||
ValueDataType = ValueDataType.STRING,
|
||||
Description = "This is an mission name of script mission.",
|
||||
IsOptional = true
|
||||
},
|
||||
}.AsReadOnly();
|
||||
|
||||
private string Name = "";
|
||||
protected override void Initialize()
|
||||
{
|
||||
base.Initialize();
|
||||
|
||||
if (ActionParameters.Count > 0)
|
||||
{
|
||||
foreach (var parameterStore in ActionParameters)
|
||||
{
|
||||
if (!parameterStore.IsOptional)
|
||||
{
|
||||
if (Action is null || Action.ActionParameters is null || !Action.ActionParameters.Any(a => a.Key == parameterStore.Key)) throw new ActionException($"Thiếu tham số bắt buộc '{parameterStore.Key}' cho action {Type}.");
|
||||
}
|
||||
}
|
||||
Parameters = Action is null || Action.ActionParameters is null ? [] : Action.ActionParameters;
|
||||
}
|
||||
|
||||
var para = Parameters.FirstOrDefault(p => p.Key == "missionName") ?? throw new ActionException($"Action {Type} không tìm thấy parameter key 'missionName'");
|
||||
if(string.IsNullOrEmpty(para.Value)) throw new ActionException($"Action {Type}, parameter 'missionName' có value rỗng");
|
||||
Name = para.Value;
|
||||
}
|
||||
|
||||
protected override Task StartAction()
|
||||
{
|
||||
return base.StartAction();
|
||||
}
|
||||
|
||||
protected override Task ExecuteAction()
|
||||
{
|
||||
return base.ExecuteAction();
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
using RobotNet.VDA5050.Type;
|
||||
|
||||
namespace RobotNet10.RobotApp.Services.Robot.Actions;
|
||||
|
||||
[RobotAction(ActionType.START_CHARGING,
|
||||
[ActionScope.INSTANT, ActionScope.NODE],
|
||||
[BlockingType.HARD],
|
||||
"Bắt đầu quá trình sạc pin.",
|
||||
"Robot đã bắt đầu sạc pin.")]
|
||||
public class RobotStartChargingAction(IServiceProvider ServiceProvider) : RobotAction(ServiceProvider)
|
||||
{
|
||||
protected override Task StartAction()
|
||||
{
|
||||
SetStatus(ActionEvent.FINISHED);
|
||||
ResultDescription = string.IsNullOrEmpty(ActionAttribute.ResultDescription) ? ResultDescription : ActionAttribute.ResultDescription;
|
||||
return base.StartAction();
|
||||
}
|
||||
|
||||
protected override Task ExecuteAction()
|
||||
{
|
||||
return base.ExecuteAction();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
using RobotNet.VDA5050.Type;
|
||||
using RobotNet10.RobotApp.Interfaces;
|
||||
|
||||
namespace RobotNet10.RobotApp.Services.Robot.Actions;
|
||||
|
||||
[RobotAction(ActionType.START_PAUSE,
|
||||
[ActionScope.INSTANT],
|
||||
[BlockingType.NONE, BlockingType.SOFT, BlockingType.HARD],
|
||||
"Tam dừng robot.",
|
||||
"Robot đã tạm dừng.")]
|
||||
public class RobotStartPauseAction(IServiceProvider ServiceProvider) : RobotAction(ServiceProvider)
|
||||
{
|
||||
protected override Task StartAction()
|
||||
{
|
||||
var RobotController = ServiceProvider.GetRequiredService<IRobotController>();
|
||||
RobotController.Pause();
|
||||
SetStatus(ActionEvent.FINISHED);
|
||||
ResultDescription = string.IsNullOrEmpty(ActionAttribute.ResultDescription) ? ResultDescription : ActionAttribute.ResultDescription;
|
||||
return base.StartAction();
|
||||
}
|
||||
|
||||
protected override Task ExecuteAction()
|
||||
{
|
||||
return base.ExecuteAction();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
using RobotNet.VDA5050.Type;
|
||||
using RobotNet10.RobotApp.Interfaces;
|
||||
|
||||
namespace RobotNet10.RobotApp.Services.Robot.Actions;
|
||||
|
||||
[RobotAction(ActionType.STATE_REQUEST,
|
||||
[ActionScope.INSTANT, ActionScope.NODE, ActionScope.EDGE],
|
||||
[BlockingType.NONE, BlockingType.SOFT, BlockingType.HARD],
|
||||
"Yêu cầu gửi trạng thái robot ngay lập tức.",
|
||||
"Robot đã gửi trạng thái ngay lập tức.")]
|
||||
public class RobotStateRequestAction(IServiceProvider ServiceProvider) : RobotAction(ServiceProvider)
|
||||
{
|
||||
protected override async Task StartAction()
|
||||
{
|
||||
var RobotStates = ServiceProvider.GetRequiredService<IState>();
|
||||
await RobotStates.PubState();
|
||||
SetStatus(ActionEvent.FINISHED);
|
||||
ResultDescription = string.IsNullOrEmpty(ActionAttribute.ResultDescription) ? ResultDescription : ActionAttribute.ResultDescription;
|
||||
}
|
||||
|
||||
protected override Task ExecuteAction()
|
||||
{
|
||||
return base.ExecuteAction();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
using RobotNet.VDA5050.Type;
|
||||
|
||||
namespace RobotNet10.RobotApp.Services.Robot.Actions;
|
||||
|
||||
[RobotAction(ActionType.STOP_CHARGING,
|
||||
[ActionScope.INSTANT, ActionScope.NODE],
|
||||
[BlockingType.HARD],
|
||||
"Kết thúc quá trình sạc pin.",
|
||||
"Robot đã kết thúc sạc pin.")]
|
||||
public class RobotStopChargingAction(IServiceProvider ServiceProvider) : RobotAction(ServiceProvider)
|
||||
{
|
||||
protected override Task StartAction()
|
||||
{
|
||||
SetStatus(ActionEvent.FINISHED);
|
||||
ResultDescription = string.IsNullOrEmpty(ActionAttribute.ResultDescription) ? ResultDescription : ActionAttribute.ResultDescription;
|
||||
return base.StartAction();
|
||||
}
|
||||
|
||||
protected override Task ExecuteAction()
|
||||
{
|
||||
return base.ExecuteAction();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
using RobotNet.VDA5050.Type;
|
||||
using RobotNet10.RobotApp.Interfaces;
|
||||
|
||||
namespace RobotNet10.RobotApp.Services.Robot.Actions;
|
||||
|
||||
[RobotAction(ActionType.STOP_PAUSE,
|
||||
[ActionScope.INSTANT],
|
||||
[BlockingType.NONE, BlockingType.SOFT, BlockingType.HARD],
|
||||
"Tiếp tục hoạt động robot sau khi tạm dừng.",
|
||||
"Robot đã tiếp tục hoạt động.")]
|
||||
public class RobotStopPauseAction(IServiceProvider ServiceProvider) : RobotAction(ServiceProvider)
|
||||
{
|
||||
protected override Task StartAction()
|
||||
{
|
||||
var RobotController = ServiceProvider.GetRequiredService<IRobotController>();
|
||||
RobotController.Resume();
|
||||
SetStatus(ActionEvent.FINISHED);
|
||||
ResultDescription = string.IsNullOrEmpty(ActionAttribute.ResultDescription) ? ResultDescription : ActionAttribute.ResultDescription;
|
||||
return base.StartAction();
|
||||
}
|
||||
|
||||
protected override Task ExecuteAction()
|
||||
{
|
||||
return base.ExecuteAction();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
using RobotNet.VDA5050.Connection;
|
||||
using RobotNet.VDA5050.Factsheet;
|
||||
using RobotNet.VDA5050.InstantAction;
|
||||
using RobotNet.VDA5050.State;
|
||||
using RobotNet.VDA5050.Type;
|
||||
using RobotNet.VDA5050.Visualization;
|
||||
|
||||
namespace RobotNet10.RobotApp.Services.Robot.Connection;
|
||||
|
||||
/// <summary>
|
||||
/// Service interface for managing MQTT connections to robots via VDA5050 protocol
|
||||
/// </summary>
|
||||
public interface IRobotConnectionsService
|
||||
{
|
||||
/// <summary>
|
||||
/// Start MQTT connection and subscribe to topics
|
||||
/// </summary>
|
||||
Task StartAsync(CancellationToken? cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Stop MQTT connection
|
||||
/// </summary>
|
||||
Task StopAsync();
|
||||
|
||||
/// <summary>
|
||||
/// Check if MQTT client is connected
|
||||
/// </summary>
|
||||
bool IsConnected { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Publish order message from robot
|
||||
/// </summary>
|
||||
Task<bool> PublishStateAsync(StateMsg state, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Publish visualization message from robot
|
||||
/// </summary>
|
||||
Task<bool> PublishVisualizationAsync(VisualizationMsg visualization, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Publish factsheet message from robot
|
||||
/// </summary>
|
||||
/// <param name="factsheet"></param>
|
||||
/// <param name="cancellationToken"></param>
|
||||
/// <returns></returns>
|
||||
Task<bool> PublishFactsheetAsync(FactSheetMsg factsheet, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Publish connection message from robot
|
||||
/// </summary>
|
||||
/// <param name="connection"></param>
|
||||
/// <param name="cancellationToken"></param>
|
||||
/// <returns></returns>
|
||||
Task<bool> PublishConnectionAsync(ConnectionMsg connection, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Publish Connection state from robot
|
||||
/// </summary>
|
||||
/// <param name="state"></param>
|
||||
/// <returns></returns>
|
||||
Task PublishConnectionStateAsync(ConnectionState state);
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
namespace RobotNet10.RobotApp.Services.Robot.Connection.Models;
|
||||
|
||||
/// <summary>
|
||||
/// VDA5050 Protocol configuration
|
||||
/// </summary>
|
||||
public class VDA5050ProtocolConfig
|
||||
{
|
||||
public string Manufacturer { get; set; } = string.Empty;
|
||||
public string Version { get; set; } = string.Empty;
|
||||
public string TopicPrefix { get; set; } = string.Empty;
|
||||
public string SerialNumber { get; set; } = string.Empty;
|
||||
}
|
||||
@@ -0,0 +1,424 @@
|
||||
using MQTTnet;
|
||||
using MQTTnet.Packets;
|
||||
using RobotNet.VDA5050;
|
||||
using RobotNet.VDA5050.Connection;
|
||||
using RobotNet.VDA5050.Factsheet;
|
||||
using RobotNet.VDA5050.InstantAction;
|
||||
using RobotNet.VDA5050.Order;
|
||||
using RobotNet.VDA5050.State;
|
||||
using RobotNet.VDA5050.Type;
|
||||
using RobotNet.VDA5050.Visualization;
|
||||
using RobotNet10.MqttConnection;
|
||||
using RobotNet10.RobotApp.Events;
|
||||
using RobotNet10.RobotApp.Services.ConfigManager;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace RobotNet10.RobotApp.Services.Robot.Connection;
|
||||
|
||||
/// <summary>
|
||||
/// Service implementation for managing MQTT connections to robots via VDA5050 protocol
|
||||
/// </summary>
|
||||
public class RobotConnectionsService(
|
||||
IConnectionConfig configManager,
|
||||
IRobotEventBus eventBus,
|
||||
IServiceProvider serviceProvider,
|
||||
Logger<RobotConnectionsService> logger,
|
||||
ILogger<MQTTClient> mqttLogger) : IRobotConnectionsService
|
||||
{
|
||||
private readonly IConnectionConfig _configManager = configManager;
|
||||
private readonly IRobotEventBus _eventBus = eventBus;
|
||||
private readonly IServiceProvider _serviceProvider = serviceProvider;
|
||||
private readonly Logger<RobotConnectionsService> _logger = logger;
|
||||
private readonly ILogger<MQTTClient> _mqttLogger = mqttLogger;
|
||||
|
||||
private MQTTClient? _mqttClient;
|
||||
private readonly SemaphoreSlim _connectionSemaphore = new(1, 1);
|
||||
|
||||
public bool IsConnected => _mqttClient is not null && _mqttClient.IsConnected;
|
||||
|
||||
public async Task StartAsync(CancellationToken? cancellationToken)
|
||||
{
|
||||
if (!_connectionSemaphore.Wait(1000)) return;
|
||||
try
|
||||
{
|
||||
if (IsConnected) return;
|
||||
|
||||
await StopAsync();
|
||||
|
||||
var mqttConfig = _configManager.GetMqttConfig();
|
||||
var vdaConfig = _configManager.GetVDA5050Config();
|
||||
|
||||
MqttTopicFilter[] topics = [
|
||||
new MqttTopicFilterBuilder().WithTopic($"{vdaConfig.TopicPrefix}/{vdaConfig.Manufacturer}/+/{VDA5050Topic.ORDER.ToJsonString()}")
|
||||
.WithAtMostOnceQoS()
|
||||
.Build(),
|
||||
new MqttTopicFilterBuilder().WithTopic($"{vdaConfig.TopicPrefix}/{vdaConfig.Manufacturer}/+/{VDA5050Topic.INSTANTACTIONS.ToJsonString()}")
|
||||
.WithAtMostOnceQoS()
|
||||
.Build()
|
||||
];
|
||||
|
||||
_mqttClient = new MQTTClient(mqttConfig, topics, _mqttLogger);
|
||||
_mqttClient.MessageUpdated += MessageUpdated;
|
||||
if (_mqttClient is not null) await _mqttClient.ConnectAsync(cancellationToken);
|
||||
if (_mqttClient is not null) await _mqttClient.SubscribeAsync(cancellationToken);
|
||||
|
||||
// Publish ONLINE once broker connection and subscriptions are ready.
|
||||
await PublishConnectionStateAsync(ConnectionState.ONLINE);
|
||||
|
||||
_logger.Info("RobotConnectionsService started successfully");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Warning($"Connection broker is failed: {ex.Message}");
|
||||
}
|
||||
finally
|
||||
{
|
||||
_connectionSemaphore.Release();
|
||||
}
|
||||
}
|
||||
|
||||
public async Task StopAsync()
|
||||
{
|
||||
if (_mqttClient is not null)
|
||||
{
|
||||
await _mqttClient.DisposeAsync();
|
||||
_mqttClient = null;
|
||||
_logger.Info("RobotConnectionsService stopped");
|
||||
}
|
||||
}
|
||||
|
||||
private async Task MessageUpdated(MqttApplicationMessageReceivedEventArgs e)
|
||||
{
|
||||
try
|
||||
{
|
||||
var topic = e.ApplicationMessage.Topic;
|
||||
var payload = Encoding.UTF8.GetString(e.ApplicationMessage.Payload);
|
||||
var (robotId, messageType) = ParseVDA5050Topic(topic);
|
||||
if (!string.IsNullOrEmpty(robotId) && !string.IsNullOrEmpty(messageType))
|
||||
{
|
||||
var vdaConfig = _configManager.GetVDA5050Config();
|
||||
if (robotId == vdaConfig.SerialNumber)
|
||||
{
|
||||
if (messageType == VDA5050Topic.ORDER.ToJsonString())
|
||||
{
|
||||
HandleOrderMessageAsync(payload);
|
||||
}
|
||||
else if (messageType == VDA5050Topic.INSTANTACTIONS.ToJsonString())
|
||||
{
|
||||
HandleInstantActionMessageAsync(payload);
|
||||
}
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.Warning("Failed to parse topic");
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Warning($"Error processing message: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private (string? robotId, string? messageType) ParseVDA5050Topic(string topic)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (string.IsNullOrEmpty(topic)) return (null, null);
|
||||
|
||||
var vdaConfig = _configManager.GetVDA5050Config();
|
||||
ReadOnlySpan<char> topicSpan = topic.AsSpan();
|
||||
var manufacturerSpan = $"/{vdaConfig.Manufacturer}/".AsSpan();
|
||||
int manufacturerIndex = topicSpan.IndexOf(manufacturerSpan);
|
||||
|
||||
if (manufacturerIndex == -1) return (null, null);
|
||||
|
||||
var remaining = topicSpan[(manufacturerIndex + manufacturerSpan.Length)..];
|
||||
int firstSlash = remaining.IndexOf('/');
|
||||
if (firstSlash == -1) return (null, null);
|
||||
|
||||
var robotId = remaining[..firstSlash].ToString();
|
||||
var messageType = remaining[(firstSlash + 1)..].ToString();
|
||||
|
||||
return (robotId, messageType);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Warning($"Parse VDA5050 Topic failed: {ex}");
|
||||
return (null, null);
|
||||
}
|
||||
}
|
||||
|
||||
private void HandleOrderMessageAsync(string payload)
|
||||
{
|
||||
try
|
||||
{
|
||||
var orderMsg = JsonSerializer.Deserialize<OrderMsg>(payload, JsonOptionExtends.Read);
|
||||
if (orderMsg is null) return;
|
||||
_eventBus.PublishOrderMessageReceived(orderMsg);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Error($"Error handling order message: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private void HandleInstantActionMessageAsync(string payload)
|
||||
{
|
||||
try
|
||||
{
|
||||
var instantActionMsg = JsonSerializer.Deserialize<InstantActionsMsg>(payload, JsonOptionExtends.Read);
|
||||
if (instantActionMsg is null) return;
|
||||
|
||||
_eventBus.PublishInstantActionMessageReceived(instantActionMsg);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Error($"Error handling instant action message: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private string BuildPublishTopic(string robotId, VDA5050Topic topic)
|
||||
{
|
||||
var vdaConfig = _configManager.GetVDA5050Config();
|
||||
return $"{vdaConfig.TopicPrefix}/{vdaConfig.Manufacturer}/{robotId}/{topic.ToJsonString()}";
|
||||
}
|
||||
|
||||
private async Task<bool> EnsureMqttClientReadyAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (_mqttClient is not null && IsConnected)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
// Startup can publish before the async connection task finishes.
|
||||
_logger.Info("Mqtt Client not initialized yet, attempting to connect...");
|
||||
await StartAsync(cancellationToken);
|
||||
|
||||
return _mqttClient is not null && IsConnected;
|
||||
}
|
||||
|
||||
public async Task<bool> PublishStateAsync(StateMsg state, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (!await EnsureMqttClientReadyAsync(cancellationToken))
|
||||
{
|
||||
_logger.Warning("Cannot publish state: MQTT client is not initialized");
|
||||
return false;
|
||||
}
|
||||
|
||||
if (state == null)
|
||||
{
|
||||
_logger.Warning("Cannot publish state: state message is null");
|
||||
return false;
|
||||
}
|
||||
|
||||
if (string.IsNullOrEmpty(state.SerialNumber))
|
||||
{
|
||||
_logger.Warning("Cannot publish state: SerialNumber is null or empty");
|
||||
return false;
|
||||
}
|
||||
|
||||
var vdaConfig = _configManager.GetVDA5050Config();
|
||||
if (state.SerialNumber != vdaConfig.SerialNumber)
|
||||
{
|
||||
_logger.Warning("Cannot publish state: state.SerialNumber is diffirent SerialNumber setting");
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!IsConnected)
|
||||
{
|
||||
_logger.Warning($"Cannot publish state: MQTT client is not connected");
|
||||
return false;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var topic = BuildPublishTopic(state.SerialNumber, VDA5050Topic.STATE);
|
||||
var data = JsonSerializer.Serialize(state, JsonOptionExtends.Write);
|
||||
await _mqttClient.PublishAsync(topic, data);
|
||||
return true;
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
_logger.Warning($"Publish state was cancelled");
|
||||
return false;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Error($"Error publishing state: {ex.Message}");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<bool> PublishVisualizationAsync(VisualizationMsg visualization, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (!await EnsureMqttClientReadyAsync(cancellationToken))
|
||||
{
|
||||
_logger.Warning("Cannot publish visualization: MQTT client is not initialized");
|
||||
return false;
|
||||
}
|
||||
|
||||
if (visualization == null)
|
||||
{
|
||||
_logger.Warning("Cannot publish visualization: visualization message is null");
|
||||
return false;
|
||||
}
|
||||
|
||||
if (string.IsNullOrEmpty(visualization.SerialNumber))
|
||||
{
|
||||
_logger.Warning("Cannot publish visualization: SerialNumber is null or empty");
|
||||
return false;
|
||||
}
|
||||
|
||||
var vdaConfig = _configManager.GetVDA5050Config();
|
||||
if (visualization.SerialNumber != vdaConfig.SerialNumber)
|
||||
{
|
||||
_logger.Warning("Cannot publish visualization: visualization.SerialNumber is diffirent SerialNumber setting");
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!IsConnected)
|
||||
{
|
||||
_logger.Warning($"Cannot publish visualization: MQTT client is not connected");
|
||||
return false;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var topic = BuildPublishTopic(visualization.SerialNumber, VDA5050Topic.VISUALIZATION);
|
||||
var data = JsonSerializer.Serialize(visualization, JsonOptionExtends.Write);
|
||||
await _mqttClient.PublishAsync(topic, data);
|
||||
return true;
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
_logger.Warning($"Publish visualization was cancelled");
|
||||
return false;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Error($"Error publishing visualization: {ex.Message}");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<bool> PublishFactsheetAsync(FactSheetMsg factsheet, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (!await EnsureMqttClientReadyAsync(cancellationToken))
|
||||
{
|
||||
_logger.Warning("Cannot publish factsheet: MQTT client is not initialized");
|
||||
return false;
|
||||
}
|
||||
|
||||
if (factsheet == null)
|
||||
{
|
||||
_logger.Warning("Cannot publish factsheet: factsheet message is null");
|
||||
return false;
|
||||
}
|
||||
|
||||
if (string.IsNullOrEmpty(factsheet.SerialNumber))
|
||||
{
|
||||
_logger.Warning("Cannot publish factsheet: SerialNumber is null or empty");
|
||||
return false;
|
||||
}
|
||||
|
||||
var vdaConfig = _configManager.GetVDA5050Config();
|
||||
if (factsheet.SerialNumber != vdaConfig.SerialNumber)
|
||||
{
|
||||
_logger.Warning("Cannot publish factsheet: factsheet.SerialNumber is diffirent SerialNumber setting");
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!IsConnected)
|
||||
{
|
||||
_logger.Warning($"Cannot publish factsheet: MQTT client is not connected");
|
||||
return false;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var topic = BuildPublishTopic(factsheet.SerialNumber, VDA5050Topic.FACTSHEET);
|
||||
var data = JsonSerializer.Serialize(factsheet, JsonOptionExtends.Write);
|
||||
await _mqttClient.PublishAsync(topic, data);
|
||||
return true;
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
_logger.Warning($"Publish factsheet was cancelled");
|
||||
return false;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Error($"Error publishing factsheet: {ex.Message}");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<bool> PublishConnectionAsync(ConnectionMsg connection, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (!await EnsureMqttClientReadyAsync(cancellationToken))
|
||||
{
|
||||
_logger.Warning("Cannot publish connection: MQTT client is not initialized");
|
||||
return false;
|
||||
}
|
||||
|
||||
if (connection == null)
|
||||
{
|
||||
_logger.Warning("Cannot publish connection: connection message is null");
|
||||
return false;
|
||||
}
|
||||
|
||||
if (string.IsNullOrEmpty(connection.SerialNumber))
|
||||
{
|
||||
_logger.Warning("Cannot publish connection: SerialNumber is null or empty");
|
||||
return false;
|
||||
}
|
||||
|
||||
var vdaConfig = _configManager.GetVDA5050Config();
|
||||
if (connection.SerialNumber != vdaConfig.SerialNumber)
|
||||
{
|
||||
_logger.Warning("Cannot publish connection: connection.SerialNumber is diffirent SerialNumber setting");
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!IsConnected)
|
||||
{
|
||||
_logger.Warning($"Cannot publish connection: MQTT client is not connected");
|
||||
return false;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var topic = BuildPublishTopic(connection.SerialNumber, VDA5050Topic.CONNECTION);
|
||||
var data = JsonSerializer.Serialize(connection, JsonOptionExtends.Write);
|
||||
await _mqttClient.PublishAsync(topic, data, retain: true);
|
||||
return true;
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
_logger.Warning($"Publish connection was cancelled");
|
||||
return false;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Error($"Error publishing connection: {ex.Message}");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task PublishConnectionStateAsync(ConnectionState state)
|
||||
{
|
||||
var vdaConfig = _configManager.GetVDA5050Config();
|
||||
var connectionMsg = new ConnectionMsg
|
||||
{
|
||||
HeaderId = 1,
|
||||
SerialNumber = vdaConfig.SerialNumber,
|
||||
Timestamp = DateTime.Now,
|
||||
Manufacturer = vdaConfig.Manufacturer,
|
||||
ConnectionState = state,
|
||||
Version = vdaConfig.Version
|
||||
};
|
||||
await PublishConnectionAsync(connectionMsg);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,280 @@
|
||||
using RobotNet.VDA5050.Type;
|
||||
using RobotNet10.RobotApp.Services.Robot.Actions;
|
||||
|
||||
namespace RobotNet10.RobotApp.Services.Robot.Helper;
|
||||
|
||||
/// <summary>
|
||||
/// Detects conflicts between actions according to VDA5050
|
||||
/// </summary>
|
||||
public class ActionConflictDetector
|
||||
{
|
||||
// VDA5050: Counter-action pairs that conflict
|
||||
private static readonly Dictionary<ActionType, ActionType> CounterActions = new()
|
||||
{
|
||||
{ ActionType.START_CHARGING, ActionType.STOP_CHARGING },
|
||||
{ ActionType.STOP_CHARGING, ActionType.START_CHARGING },
|
||||
{ ActionType.START_PAUSE, ActionType.STOP_PAUSE },
|
||||
{ ActionType.STOP_PAUSE, ActionType.START_PAUSE },
|
||||
};
|
||||
|
||||
// Actions that target the same resource and cannot run simultaneously
|
||||
private static readonly HashSet<ActionType> LoadHandlingActions =
|
||||
[
|
||||
ActionType.PICK,
|
||||
ActionType.DROP,
|
||||
ActionType.LIFT_ROTATE,
|
||||
ActionType.ROTATE,
|
||||
ActionType.ROTATE_KEEP_LIFT
|
||||
];
|
||||
|
||||
private static readonly HashSet<ActionType> ChargingActions =
|
||||
[
|
||||
ActionType.START_CHARGING,
|
||||
ActionType.STOP_CHARGING
|
||||
];
|
||||
|
||||
// Actions that use the Navigation module - cannot run simultaneously
|
||||
private static readonly HashSet<ActionType> NavigationActions =
|
||||
[
|
||||
ActionType.DOCK_TO,
|
||||
ActionType.MOVE_STRAIGHT_TO_COOR,
|
||||
ActionType.MOVE_STRAIGHT_WITH_DISTANCE,
|
||||
ActionType.FINE_POSITIONING,
|
||||
ActionType.INIT_POSITION,
|
||||
ActionType.START_CHARGING,
|
||||
ActionType.STOP_CHARGING
|
||||
];
|
||||
|
||||
// Functional module actions - cannot run while robot is moving (navigation active)
|
||||
private static readonly HashSet<ActionType> FunctionalModuleActions =
|
||||
[
|
||||
ActionType.PICK,
|
||||
ActionType.DROP,
|
||||
ActionType.LIFT_ROTATE,
|
||||
ActionType.ROTATE,
|
||||
ActionType.ROTATE_KEEP_LIFT,
|
||||
ActionType.DOCK_TO,
|
||||
ActionType.DETECT_OBJECT,
|
||||
ActionType.START_CHARGING,
|
||||
ActionType.STOP_CHARGING,
|
||||
ActionType.FINE_POSITIONING
|
||||
];
|
||||
|
||||
/// <summary>
|
||||
/// Check if instant action conflicts with any running actions (ORDER or INSTANT)
|
||||
/// </summary>
|
||||
public ConflictResult CheckConflict(
|
||||
RobotNet.VDA5050.InstantAction.Action instantAction,
|
||||
IEnumerable<RobotAction> runningActions,
|
||||
bool isOrderActive = false,
|
||||
bool isDriving = false)
|
||||
{
|
||||
if (!RobotNet.VDA5050.EnumHelper.TryParse(instantAction.ActionType, out ActionType instantType))
|
||||
{
|
||||
return ConflictResult.Invalid("Invalid action type");
|
||||
}
|
||||
|
||||
// VDA5050: cancelOrder and read-only actions must NEVER be blocked
|
||||
if (instantType == ActionType.CANCEL_ORDER ||
|
||||
instantType == ActionType.STATE_REQUEST ||
|
||||
instantType == ActionType.FACTSHEET_REQUEST)
|
||||
{
|
||||
return ConflictResult.NoConflict();
|
||||
}
|
||||
|
||||
// Check: Navigation instant action while Order is active
|
||||
if (isOrderActive && NavigationActions.Contains(instantType))
|
||||
{
|
||||
return ConflictResult.Conflict(
|
||||
ConflictType.NavigationOrderConflict,
|
||||
$"Navigation action {instantType} rejected - Order is active, cannot execute navigation instant actions",
|
||||
null
|
||||
);
|
||||
}
|
||||
|
||||
// Check: Functional module or navigation action while robot is driving
|
||||
if (isDriving && (FunctionalModuleActions.Contains(instantType) || NavigationActions.Contains(instantType)))
|
||||
{
|
||||
return ConflictResult.Conflict(
|
||||
ConflictType.DrivingConflict,
|
||||
$"Action {instantType} rejected - robot is currently moving",
|
||||
null
|
||||
);
|
||||
}
|
||||
|
||||
foreach (var runningAction in runningActions)
|
||||
{
|
||||
// 1. Check counter-action conflict
|
||||
var counterConflict = CheckCounterActionConflict(instantType, runningAction);
|
||||
if (counterConflict.HasConflict)
|
||||
{
|
||||
return counterConflict;
|
||||
}
|
||||
|
||||
// 2. Check resource conflict
|
||||
var resourceConflict = CheckResourceConflict(instantAction, instantType, runningAction);
|
||||
if (resourceConflict.HasConflict)
|
||||
{
|
||||
return resourceConflict;
|
||||
}
|
||||
|
||||
// 3. Check navigation conflict (two navigation actions cannot run simultaneously)
|
||||
var navConflict = CheckNavigationConflict(instantType, runningAction);
|
||||
if (navConflict.HasConflict)
|
||||
{
|
||||
return navConflict;
|
||||
}
|
||||
|
||||
// 4. Check BlockingType conflict
|
||||
var blockingConflict = CheckBlockingTypeConflict(instantAction, runningAction);
|
||||
if (blockingConflict.HasConflict)
|
||||
{
|
||||
return blockingConflict;
|
||||
}
|
||||
}
|
||||
|
||||
return ConflictResult.NoConflict();
|
||||
}
|
||||
|
||||
private static ConflictResult CheckCounterActionConflict(ActionType instantType, RobotAction runningAction)
|
||||
{
|
||||
if (CounterActions.TryGetValue(instantType, out var counterType) &&
|
||||
counterType == runningAction.Type)
|
||||
{
|
||||
return ConflictResult.Conflict(
|
||||
ConflictType.CounterAction,
|
||||
$"InstantAction {instantType} conflicts with running action {runningAction.Type}",
|
||||
runningAction.Id
|
||||
);
|
||||
}
|
||||
|
||||
return ConflictResult.NoConflict();
|
||||
}
|
||||
|
||||
private static ConflictResult CheckResourceConflict(
|
||||
RobotNet.VDA5050.InstantAction.Action instantAction,
|
||||
ActionType instantType,
|
||||
RobotAction runningAction)
|
||||
{
|
||||
// Check if both actions target load handling
|
||||
if (LoadHandlingActions.Contains(instantType) &&
|
||||
LoadHandlingActions.Contains(runningAction.Type))
|
||||
{
|
||||
// Check if same LHD (Load Handling Device)
|
||||
var instantLhd = GetParameterValue(instantAction.ActionParameters, "lhd");
|
||||
var orderLhd = GetParameterValue(runningAction.Parameters, "lhd");
|
||||
|
||||
// If both specify LHD and they're the same, or if neither specifies (default LHD)
|
||||
if (string.IsNullOrEmpty(instantLhd) || string.IsNullOrEmpty(orderLhd) ||
|
||||
instantLhd == orderLhd)
|
||||
{
|
||||
return ConflictResult.Conflict(
|
||||
ConflictType.ResourceConflict,
|
||||
$"InstantAction {instantType} conflicts with {runningAction.Type} - same Load Handling Device",
|
||||
runningAction.Id
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Check if both actions target charging
|
||||
if (ChargingActions.Contains(instantType) &&
|
||||
ChargingActions.Contains(runningAction.Type))
|
||||
{
|
||||
return ConflictResult.Conflict(
|
||||
ConflictType.ResourceConflict,
|
||||
$"InstantAction {instantType} conflicts with {runningAction.Type} - same charging system",
|
||||
runningAction.Id
|
||||
);
|
||||
}
|
||||
|
||||
return ConflictResult.NoConflict();
|
||||
}
|
||||
|
||||
private static ConflictResult CheckNavigationConflict(ActionType instantType, RobotAction runningAction)
|
||||
{
|
||||
// Two navigation actions cannot run simultaneously
|
||||
if (NavigationActions.Contains(instantType) &&
|
||||
NavigationActions.Contains(runningAction.Type) &&
|
||||
!runningAction.IsCompleted)
|
||||
{
|
||||
return ConflictResult.Conflict(
|
||||
ConflictType.NavigationConflict,
|
||||
$"Navigation action {instantType} conflicts with running navigation action {runningAction.Type}",
|
||||
runningAction.Id
|
||||
);
|
||||
}
|
||||
|
||||
return ConflictResult.NoConflict();
|
||||
}
|
||||
|
||||
private static ConflictResult CheckBlockingTypeConflict(
|
||||
RobotNet.VDA5050.InstantAction.Action instantAction,
|
||||
RobotAction runningAction)
|
||||
{
|
||||
// HARD instant action cannot run when HARD action is running
|
||||
if (instantAction.BlockingType == BlockingType.HARD &&
|
||||
runningAction.BlockingType == BlockingType.HARD &&
|
||||
!runningAction.IsCompleted)
|
||||
{
|
||||
return ConflictResult.Conflict(
|
||||
ConflictType.BlockingTypeConflict,
|
||||
$"InstantAction (HARD) cannot run while action {runningAction.Type} (HARD) is running",
|
||||
runningAction.Id
|
||||
);
|
||||
}
|
||||
|
||||
return ConflictResult.NoConflict();
|
||||
}
|
||||
|
||||
private static string? GetParameterValue(
|
||||
RobotNet.VDA5050.InstantAction.ActionParameter[]? parameters,
|
||||
string key)
|
||||
{
|
||||
return parameters?.FirstOrDefault(p => p.Key == key)?.Value;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of conflict detection
|
||||
/// </summary>
|
||||
public class ConflictResult
|
||||
{
|
||||
public bool HasConflict { get; init; }
|
||||
public ConflictType Type { get; init; }
|
||||
public string Description { get; init; } = "";
|
||||
public string? ConflictingActionId { get; init; }
|
||||
|
||||
public static ConflictResult NoConflict() => new() { HasConflict = false };
|
||||
|
||||
public static ConflictResult Conflict(ConflictType type, string description, string? conflictingActionId = null)
|
||||
=> new()
|
||||
{
|
||||
HasConflict = true,
|
||||
Type = type,
|
||||
Description = description,
|
||||
ConflictingActionId = conflictingActionId
|
||||
};
|
||||
|
||||
public static ConflictResult Invalid(string description)
|
||||
=> new()
|
||||
{
|
||||
HasConflict = true,
|
||||
Type = ConflictType.Invalid,
|
||||
Description = description
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Types of conflicts
|
||||
/// </summary>
|
||||
public enum ConflictType
|
||||
{
|
||||
None,
|
||||
CounterAction, // e.g., startCharging vs stopCharging
|
||||
ResourceConflict, // e.g., two pick actions on same LHD
|
||||
NavigationConflict, // e.g., two navigation actions (dockTo vs moveStraight)
|
||||
NavigationOrderConflict,// Navigation instant action while Order is active
|
||||
DrivingConflict, // Functional module action while robot is moving
|
||||
BlockingTypeConflict, // e.g., HARD vs HARD
|
||||
Invalid // Invalid action type or parameters
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
using RobotNet.VDA5050.Order;
|
||||
using RobotNet.VDA5050.Type;
|
||||
using RobotNet10.Common;
|
||||
using RobotNet10.RobotApp.Services.Exceptions;
|
||||
using RobotNet10.RobotApp.Services.Robot.Models;
|
||||
using RobotNet10.RobotApp.Shared.Enums;
|
||||
|
||||
namespace RobotNet10.RobotApp.Services.Robot.Helper;
|
||||
|
||||
public class OrderConverter
|
||||
{
|
||||
public static (OrderNode[] Nodes, OrderEdge[] Edges) Validate(Node[] nodes, Edge[] edges, double currentTheta)
|
||||
{
|
||||
if (nodes.Length < 2) throw new PathPlannerException(RobotErrors.Error1002(nodes.Length));
|
||||
if (edges.Length != nodes.Length - 1) throw new PathPlannerException(RobotErrors.Error1004(nodes.Length, edges.Length));
|
||||
|
||||
OrderNode[] orderNodes = [..nodes.Select(n => new OrderNode
|
||||
{
|
||||
NodeId = n.NodeId,
|
||||
SequenceId = n.SequenceId,
|
||||
X = n.NodePosition?.X ?? 0,
|
||||
Y = n.NodePosition?.Y ?? 0,
|
||||
Theta = n.NodePosition?.Theta,
|
||||
AllowedDeviationXY = n.NodePosition?.AllowedDeviationXY,
|
||||
AllowedDeviationTheta = n.NodePosition?.AllowedDeviationTheta,
|
||||
})];
|
||||
|
||||
List<OrderEdge> orderEdges = [];
|
||||
foreach (var edge in edges)
|
||||
{
|
||||
var trajectory = edge.Trajectory;
|
||||
var controlPoints = trajectory?.ControlPoints;
|
||||
orderEdges.Add(new()
|
||||
{
|
||||
EdgeId = edge.EdgeId,
|
||||
SequenceId = edge.SequenceId,
|
||||
StartNodeId = edge.StartNodeId,
|
||||
EndNodeId = edge.EndNodeId,
|
||||
Orientation = edge.Orientation,
|
||||
OrientationType = edge.OrientationType,
|
||||
RotationAllowed = edge.RotationAllowed,
|
||||
Speed = edge.MaxSpeed,
|
||||
Degree = edge.Trajectory?.Degree ?? 1,
|
||||
ControlPoint1X = controlPoints is { Length: > 2 } ? controlPoints[1].X : 0,
|
||||
ControlPoint1Y = controlPoints is { Length: > 2 } ? controlPoints[1].Y : 0,
|
||||
ControlPoint2X = controlPoints is { Length: > 3 } ? controlPoints[2].X : 0,
|
||||
ControlPoint2Y = controlPoints is { Length: > 3 } ? controlPoints[2].Y : 0,
|
||||
});
|
||||
}
|
||||
// cần xử lí để lấy direction
|
||||
var currentDirection = GetDirectionInNode(nodes[0].NodePosition?.Theta ?? currentTheta, orderNodes[0], orderNodes[1], orderEdges[0]);
|
||||
for(int i = 0; i < orderEdges.Count; i++)
|
||||
{
|
||||
currentDirection = OrientationToDirection(currentDirection, orderNodes[i], orderNodes[i + 1], orderEdges[i]);
|
||||
orderEdges[i].Direction = currentDirection;
|
||||
orderNodes[i].ContinueTheta = GetAngleInNodeStart(orderNodes[i], orderNodes[i + 1], orderEdges[i]);
|
||||
if (i > 0)
|
||||
{
|
||||
var inNodeAngle = GetAngleInNodeEnd(orderNodes[i], orderNodes[i - 1], orderEdges[i - 1]);
|
||||
if (orderNodes[i].Theta is { } theta && Math.Abs(SpaceCompute.NormalizeRadianAngle(inNodeAngle) - SpaceCompute.NormalizeRadianAngle(theta)) > 0.04)
|
||||
{
|
||||
orderNodes[i].IsWaitRotating = true;
|
||||
}
|
||||
if (!orderNodes[i].IsWaitRotating && orderNodes[i].ContinueTheta is { } continueTheta)
|
||||
{
|
||||
if (Math.Abs(SpaceCompute.NormalizeRadianAngle(inNodeAngle) - SpaceCompute.NormalizeRadianAngle(continueTheta)) > 0.785)
|
||||
{
|
||||
orderNodes[i].IsWaitRotating = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return (orderNodes , [..orderEdges]);
|
||||
}
|
||||
|
||||
private static RobotDirection ConvertTangentialOrientation(double orientation)
|
||||
{
|
||||
// Normalize về [0, 2*PI] để dễ xử lý
|
||||
double normalizedAngle = SpaceCompute.NormalizeRadianAngle(orientation);
|
||||
if (normalizedAngle < 0) normalizedAngle += 2 * Math.PI;
|
||||
|
||||
// Forward: orientation gần 0 (hoặc 2*PI)
|
||||
// Backward: orientation gần PI
|
||||
|
||||
// Kiểm tra gần 0 hoặc 2*PI (Forward)
|
||||
if (normalizedAngle <= Math.PI / 2 || normalizedAngle >= 3 * Math.PI / 2)
|
||||
{
|
||||
return RobotDirection.FORWARD;
|
||||
}
|
||||
// Kiểm tra gần PI (Backward)
|
||||
else
|
||||
{
|
||||
return RobotDirection.BACKWARD;
|
||||
}
|
||||
}
|
||||
|
||||
private static RobotDirection ConvertGlobalOrientation(double orientation, OrderNode inNode, OrderNode futureNode, OrderEdge edge)
|
||||
{
|
||||
(double futurex, double futurey) = SpaceCompute.BezierPoint(0.1, new()
|
||||
{
|
||||
StartX = inNode.X,
|
||||
StartY = inNode.Y,
|
||||
EndX = futureNode.X,
|
||||
EndY = futureNode.Y,
|
||||
ControlPoint1X = edge.ControlPoint1X ?? 0,
|
||||
ControlPoint1Y = edge.ControlPoint1Y ?? 0,
|
||||
ControlPoint2X = edge.ControlPoint2X ?? 0,
|
||||
ControlPoint2Y = edge.ControlPoint2Y ?? 0,
|
||||
Degree = edge.Degree,
|
||||
});
|
||||
|
||||
var edgeAngle = Math.Atan2(futurey - inNode.Y, futurex - inNode.X);
|
||||
|
||||
// Tính góc chênh lệch giữa orientation và edge angle
|
||||
double angleDiff = SpaceCompute.NormalizeRadianAngle(orientation - edgeAngle);
|
||||
|
||||
// Nếu góc chênh lệch gần 0 -> Forward
|
||||
// Nếu góc chênh lệch gần PI -> Backward
|
||||
double absAngleDiff = Math.Abs(angleDiff);
|
||||
|
||||
if (absAngleDiff <= Math.PI / 2)
|
||||
{
|
||||
return RobotDirection.FORWARD;
|
||||
}
|
||||
else
|
||||
{
|
||||
return RobotDirection.BACKWARD;
|
||||
}
|
||||
}
|
||||
|
||||
private static RobotDirection GetDirectionInNode(double currentTheta, OrderNode inNode, OrderNode futureNode, OrderEdge edge)
|
||||
{
|
||||
(double futurex, double futurey) = SpaceCompute.BezierPoint(0.1, new()
|
||||
{
|
||||
StartX = inNode.X,
|
||||
StartY = inNode.Y,
|
||||
EndX = futureNode.X,
|
||||
EndY = futureNode.Y,
|
||||
ControlPoint1X = edge.ControlPoint1X ?? 0,
|
||||
ControlPoint1Y = edge.ControlPoint1Y ?? 0,
|
||||
ControlPoint2X = edge.ControlPoint2X ?? 0,
|
||||
ControlPoint2Y = edge.ControlPoint2Y ?? 0,
|
||||
Degree = edge.Degree,
|
||||
});
|
||||
(double robotx, double roboty) =
|
||||
(
|
||||
inNode.X + Math.Cos(currentTheta),
|
||||
inNode.Y + Math.Sin(currentTheta)
|
||||
);
|
||||
|
||||
var angle = SpaceCompute.GetVectorAngle(
|
||||
inNode.X,
|
||||
inNode.Y,
|
||||
robotx,
|
||||
roboty,
|
||||
futurex,
|
||||
futurey);
|
||||
return angle > 90 ? RobotDirection.BACKWARD : RobotDirection.FORWARD;
|
||||
}
|
||||
|
||||
private static double GetAngleInNodeEnd(OrderNode inNode, OrderNode oldNode, OrderEdge edge)
|
||||
{
|
||||
(double oldX, double oldY) = SpaceCompute.BezierPoint(0.9, new()
|
||||
{
|
||||
StartX = oldNode.X,
|
||||
StartY = oldNode.Y,
|
||||
EndX = inNode.X,
|
||||
EndY = inNode.Y,
|
||||
ControlPoint1X = edge.ControlPoint1X ?? 0,
|
||||
ControlPoint1Y = edge.ControlPoint1Y ?? 0,
|
||||
ControlPoint2X = edge.ControlPoint2X ?? 0,
|
||||
ControlPoint2Y = edge.ControlPoint2Y ?? 0,
|
||||
Degree = edge.Degree,
|
||||
});
|
||||
var dy = inNode.Y - oldY;
|
||||
var dx = inNode.X - oldX;
|
||||
return edge.Direction == RobotDirection.FORWARD ? Math.Atan2(dy, dx) : Math.Atan2(-dy, -dx);
|
||||
}
|
||||
|
||||
private static double GetAngleInNodeStart(OrderNode inNode, OrderNode futureNode, OrderEdge edge)
|
||||
{
|
||||
(double futureX, double futureY) = SpaceCompute.BezierPoint(0.1, new()
|
||||
{
|
||||
StartX = inNode.X,
|
||||
StartY = inNode.Y,
|
||||
EndX = futureNode.X,
|
||||
EndY = futureNode.Y,
|
||||
ControlPoint1X = edge.ControlPoint1X ?? 0,
|
||||
ControlPoint1Y = edge.ControlPoint1Y ?? 0,
|
||||
ControlPoint2X = edge.ControlPoint2X ?? 0,
|
||||
ControlPoint2Y = edge.ControlPoint2Y ?? 0,
|
||||
Degree = edge.Degree,
|
||||
});
|
||||
var dy = futureY - inNode.Y;
|
||||
var dx = futureX - inNode.X;
|
||||
return edge.Direction == RobotDirection.FORWARD ? Math.Atan2(dy, dx) : Math.Atan2(-dy, -dx);
|
||||
}
|
||||
|
||||
public static RobotDirection OrientationToDirection(RobotDirection currentDirection, OrderNode inNode, OrderNode futureNode, OrderEdge edge)
|
||||
{
|
||||
if(edge.Orientation.HasValue && edge.OrientationType is not null)
|
||||
{
|
||||
switch (edge.OrientationType)
|
||||
{
|
||||
case OrientationType.TANGENTIAL:
|
||||
return ConvertTangentialOrientation(edge.Orientation.Value);
|
||||
|
||||
case OrientationType.GLOBAL:
|
||||
return ConvertGlobalOrientation(edge.Orientation.Value, inNode, futureNode, edge);
|
||||
}
|
||||
}
|
||||
if (inNode.Theta.HasValue) return GetDirectionInNode(inNode.Theta.Value, inNode, futureNode, edge);
|
||||
return currentDirection;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
using System.Diagnostics;
|
||||
using System.Runtime.InteropServices;
|
||||
|
||||
namespace RobotNet10.RobotApp.Services.Robot;
|
||||
|
||||
internal static partial class Windows
|
||||
{
|
||||
[LibraryImport("winmm.dll")]
|
||||
internal static partial uint timeBeginPeriod(uint uPeriod);
|
||||
|
||||
[LibraryImport("winmm.dll")]
|
||||
internal static partial uint timeEndPeriod(uint uPeriod);
|
||||
}
|
||||
|
||||
public static class HighPrecisionTimerHelper
|
||||
{
|
||||
public static void EnableHighPrecision()
|
||||
{
|
||||
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
|
||||
{
|
||||
_ = Windows.timeBeginPeriod(2);
|
||||
}
|
||||
}
|
||||
|
||||
public static void DisableHighPrecision()
|
||||
{
|
||||
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
|
||||
{
|
||||
_ = Windows.timeEndPeriod(2);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public class HighPrecisionTimer<T>(int Interval, Action Callback, Logger<T>? Logger) : IDisposable where T : class
|
||||
{
|
||||
public bool Disposed;
|
||||
private Thread? Thread;
|
||||
private long IntervalTicks;
|
||||
private long NextDueTime;
|
||||
private readonly Lock Lock = new();
|
||||
|
||||
private void Handler()
|
||||
{
|
||||
while (!Disposed)
|
||||
{
|
||||
long now = Stopwatch.GetTimestamp();
|
||||
|
||||
bool shouldRun = false;
|
||||
|
||||
lock (Lock)
|
||||
{
|
||||
if (Disposed) break;
|
||||
if (now >= NextDueTime)
|
||||
{
|
||||
shouldRun = true;
|
||||
long scheduledTime = NextDueTime;
|
||||
NextDueTime += IntervalTicks;
|
||||
|
||||
// Tự đồng bộ nếu lệch quá
|
||||
long driftTicks = now - scheduledTime;
|
||||
if (driftTicks > IntervalTicks / 2)
|
||||
{
|
||||
Logger?.Warning($"High-res timer drift: {driftTicks * 1000.0 / Stopwatch.Frequency:F3}ms. Resync.");
|
||||
NextDueTime = now + IntervalTicks;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// === BƯỚC 2: Chạy callback ===
|
||||
if (shouldRun)
|
||||
{
|
||||
try
|
||||
{
|
||||
Callback.Invoke();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger?.Error($"Callback error in high-precision timer: {ex}");
|
||||
}
|
||||
}
|
||||
|
||||
// === BƯỚC 3: Chờ chính xác đến lần sau ===
|
||||
long sleepUntil = NextDueTime;
|
||||
while (!Disposed)
|
||||
{
|
||||
now = Stopwatch.GetTimestamp();
|
||||
long remaining = sleepUntil - now;
|
||||
|
||||
if (remaining <= 0)
|
||||
break;
|
||||
|
||||
// > 1ms → Sleep
|
||||
if (remaining > Stopwatch.Frequency / 1000)
|
||||
{
|
||||
Thread.Sleep(1);
|
||||
}
|
||||
// < 1ms → SpinWait
|
||||
else
|
||||
{
|
||||
Thread.SpinWait((int)(remaining / 10));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public void Start()
|
||||
{
|
||||
if (!Disposed)
|
||||
{
|
||||
lock (Lock)
|
||||
{
|
||||
if (Interval < 30) HighPrecisionTimerHelper.EnableHighPrecision();
|
||||
IntervalTicks = (long)(Interval * (Stopwatch.Frequency / 1000.0));
|
||||
Thread = new Thread(Handler) { IsBackground = true, Priority = ThreadPriority.Highest };
|
||||
NextDueTime = Stopwatch.GetTimestamp() + IntervalTicks;
|
||||
Thread.Start();
|
||||
}
|
||||
}
|
||||
else throw new ObjectDisposedException(nameof(HighPrecisionTimer<T>));
|
||||
}
|
||||
|
||||
public void Stop()
|
||||
{
|
||||
if (Disposed) return;
|
||||
|
||||
if (Thread != null)
|
||||
{
|
||||
Disposed = true;
|
||||
lock (Lock)
|
||||
{
|
||||
Thread.Join(100);
|
||||
Thread = null;
|
||||
HighPrecisionTimerHelper.DisableHighPrecision();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
Dispose(true);
|
||||
GC.SuppressFinalize(this);
|
||||
}
|
||||
|
||||
protected virtual void Dispose(bool disposing)
|
||||
{
|
||||
if (Disposed) return;
|
||||
|
||||
if (disposing) Stop();
|
||||
|
||||
Disposed = true;
|
||||
}
|
||||
|
||||
~HighPrecisionTimer()
|
||||
{
|
||||
Dispose(false);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
using RobotNet.VDA5050.Order;
|
||||
using RobotNet.VDA5050.Type;
|
||||
using RobotNet10.RobotApp.Shared.Enums;
|
||||
|
||||
namespace RobotNet10.RobotApp.Services.Robot.Models;
|
||||
|
||||
public class OrderEdge
|
||||
{
|
||||
public string EdgeId { get; set; } = string.Empty;
|
||||
public int SequenceId { get; set; }
|
||||
public string StartNodeId { get; set; } = string.Empty;
|
||||
public string EndNodeId { get; set; } = string.Empty;
|
||||
|
||||
public double? Orientation { get; set; }
|
||||
public double? Speed { get; set; }
|
||||
public OrientationType? OrientationType { get; set; }
|
||||
public RobotDirection Direction { get; set; }
|
||||
public bool? RotationAllowed { get; set; }
|
||||
|
||||
public int Degree { get; set; }
|
||||
public double? ControlPoint1X { get; set; }
|
||||
public double? ControlPoint1Y { get; set; }
|
||||
public double? ControlPoint2X { get; set; }
|
||||
public double? ControlPoint2Y { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
namespace RobotNet10.RobotApp.Services.Robot.Models;
|
||||
|
||||
public class OrderNode
|
||||
{
|
||||
public string NodeId { get; set; } = string.Empty;
|
||||
public int SequenceId { get; set; }
|
||||
public double X { get; set; }
|
||||
public double Y { get; set; }
|
||||
public double? Theta { get; set; }
|
||||
public double? AllowedDeviationXY { get; set; }
|
||||
public double? AllowedDeviationTheta { get; set; }
|
||||
public bool IsWaitRotating { get; set; }
|
||||
public double? ContinueTheta { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
using RobotNet10.RobotApp.Interfaces;
|
||||
using RobotNet10.RobotApp.Xloc;
|
||||
using RobotNet10.RobotApp.Services.ConfigManager;
|
||||
using RobotNet10.RobotApp.Services.Simulation;
|
||||
using RobotNet10.Shared;
|
||||
using RobotNet10.Shared.Geometry;
|
||||
using RobotNet10.RobotApp.Shared.Enums;
|
||||
|
||||
namespace RobotNet10.RobotApp.Services.Robot.Modules;
|
||||
|
||||
|
||||
public class RobotLocalization(IRobotConfiguration RobotConfiguration,
|
||||
XlocIntegrationService xlocService,
|
||||
SimulationVisualization SimVisualization,
|
||||
Logger<RobotLocalization> Logger)
|
||||
: ILocalization
|
||||
{
|
||||
public double X => IsSimulation ? SimVisualization.X : GetXlocX();
|
||||
public double Y => IsSimulation ? SimVisualization.Y : GetXlocY();
|
||||
public double Theta => IsSimulation ? SimVisualization.Theta * Math.PI / 180 : GetXlocTheta();
|
||||
public bool IsReady => IsSimulation ? true : IsXlocReady();
|
||||
public string CurrentActiveMap => IsSimulation ? "" : GetXlocCurrentActiveMap();
|
||||
public double DeviationRange { get; private set; }
|
||||
public double LocalizationScore => IsSimulation ? 1.0 : GetXlocLocalizationScore();
|
||||
public bool PositionInitialized => IsSimulation ? true : GetXlocPositionInitialized();
|
||||
private bool IsSimulation => RobotConfiguration.GetSimulationConfig().IsEnable;
|
||||
|
||||
private double GetXlocX()
|
||||
{
|
||||
var pose = xlocService.GetCurrentPose2D();
|
||||
return pose?.x ?? 0.0;
|
||||
}
|
||||
|
||||
private double GetXlocY()
|
||||
{
|
||||
var pose = xlocService.GetCurrentPose2D();
|
||||
return pose?.y ?? 0.0;
|
||||
}
|
||||
|
||||
private double GetXlocTheta()
|
||||
{
|
||||
var pose = xlocService.GetCurrentPose2D();
|
||||
return pose?.yaw ?? 0.0;
|
||||
}
|
||||
|
||||
private bool IsXlocReady()
|
||||
{
|
||||
var diagnostics = xlocService.GetDiagnostics();
|
||||
// 0=MAPPING, 1=LOCALIZATION, 2=PROCESSING, 3=READY, 4=ERROR. Accept 1,2,3 so orders are allowed once localizing.
|
||||
if (diagnostics == null) return false;
|
||||
return diagnostics.XlocState is 1 or 2 or 3;
|
||||
}
|
||||
|
||||
private string GetXlocCurrentActiveMap()
|
||||
{
|
||||
var diagnostics = xlocService.GetDiagnostics();
|
||||
return diagnostics?.CurrentActiveMap ?? "";
|
||||
}
|
||||
|
||||
private double GetXlocLocalizationScore()
|
||||
{
|
||||
var diagnostics = xlocService.GetDiagnostics();
|
||||
return diagnostics?.Reliability ?? 0.0; // Use Reliability (0.0 to 1.0) as LocalizationScore
|
||||
}
|
||||
|
||||
private bool GetXlocPositionInitialized()
|
||||
{
|
||||
// Position is initialized if we have a valid pose from XLOC and it's not in ERROR state
|
||||
var pose = xlocService.GetCurrentPose2D();
|
||||
var diagnostics = xlocService.GetDiagnostics();
|
||||
|
||||
return pose.HasValue && diagnostics?.XlocState != 4; // 4 = ERROR
|
||||
}
|
||||
|
||||
public double DistanceTo(double x, double y)
|
||||
{
|
||||
return Math.Sqrt(Math.Pow(x - X, 2) + Math.Pow(y - Y, 2));
|
||||
}
|
||||
|
||||
public MessageResult SetInitializePosition(double x, double y, double theta)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (IsSimulation)
|
||||
{
|
||||
SimVisualization.LocalizationInitialize(x, y, theta * 180 / Math.PI);
|
||||
return new(true);
|
||||
}
|
||||
else
|
||||
{
|
||||
// Use XlocIntegrationService to set initial pose
|
||||
// theta is in radians, convert to radians for xloc (it expects radians)
|
||||
bool result = xlocService.SetInitialPose(x, y, 0.0, 0.0, 0.0, theta);
|
||||
if (result)
|
||||
{
|
||||
return new(true, "Initial position set successfully");
|
||||
}
|
||||
else
|
||||
{
|
||||
return new(false, "Failed to set initial position");
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.Warning($"Initialize robot position failed: {ex.Message}");
|
||||
return new(false, $"Initialize robot position failed: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
// private bool GetIsReady()
|
||||
// {
|
||||
// if (IsSimulation) return true;
|
||||
// return xlocService.IsReady;
|
||||
// }
|
||||
}
|
||||
@@ -0,0 +1,339 @@
|
||||
using RobotNet.VDA5050.Order;
|
||||
using RobotNet10.RobotApp.Detection;
|
||||
using RobotNet10.RobotApp.Interfaces;
|
||||
using RobotNet10.RobotApp.Services.ConfigManager;
|
||||
using RobotNet10.RobotApp.Services.Exceptions;
|
||||
using RobotNet10.RobotApp.Services.Simulation;
|
||||
using RobotNet10.RobotApp.Shared.Enums;
|
||||
|
||||
namespace RobotNet10.RobotApp.Services.Robot.Modules;
|
||||
|
||||
public class RobotNavigation(
|
||||
IRobotConfiguration robotConfiguration,
|
||||
IServiceProvider serviceProvider,
|
||||
RobotNet10.RobotApp.Navigation.NavigationIntegrationService navigationIntegrationService,
|
||||
ILogger<RobotNavigation> logger) : INavigation
|
||||
{
|
||||
public bool IsReady { get; private set; }
|
||||
private bool _navResultSubscribed;
|
||||
|
||||
public bool Driving
|
||||
{
|
||||
get
|
||||
{
|
||||
if (IsSimulation)
|
||||
return SimNavigation?.Driving ?? false;
|
||||
|
||||
var feedback = navigationIntegrationService.GetFeedback();
|
||||
if (feedback == null) return false;
|
||||
return feedback.NavigationState is RobotNet10.RobotApp.Navigation.NavigationState.Controlling;
|
||||
// return feedback.NavigationState is RobotNet10.RobotApp.Navigation.NavigationState.Active
|
||||
// or RobotNet10.RobotApp.Navigation.NavigationState.Planning
|
||||
// or RobotNet10.RobotApp.Navigation.NavigationState.Controlling;
|
||||
}
|
||||
}
|
||||
|
||||
public double VelocityX => IsSimulation ? (SimNavigation?.VelocityX ?? 0) : (navigationIntegrationService.GetTwist()?.x ?? 0);
|
||||
public double VelocityY => IsSimulation ? (SimNavigation?.VelocityY ?? 0) : (navigationIntegrationService.GetTwist()?.y ?? 0);
|
||||
public double Omega => IsSimulation ? (SimNavigation?.Omega ?? 0) : (navigationIntegrationService.GetTwist()?.theta ?? 0);
|
||||
public RobotNet10.RobotApp.Interfaces.NavigationState State => _lastFinishedState ?? (IsSimulation ? (SimNavigation?.State ?? RobotNet10.RobotApp.Interfaces.NavigationState.Idle) : MapNavigationState(navigationIntegrationService.GetFeedback()?.NavigationState));
|
||||
|
||||
public IReadOnlyList<NavigationNode>? CurrentPath
|
||||
{
|
||||
get
|
||||
{
|
||||
if (IsSimulation)
|
||||
return null;
|
||||
|
||||
var globalPath = navigationIntegrationService.GetGlobalPathData();
|
||||
if (globalPath == null || globalPath.Points.Count == 0)
|
||||
return null;
|
||||
|
||||
return globalPath.Points.Select(p => new NavigationNode
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
X = p.X,
|
||||
Y = p.Y,
|
||||
Theta = p.Theta
|
||||
}).ToList();
|
||||
}
|
||||
}
|
||||
|
||||
// C API navigation currently does not expose these dock monitoring values.
|
||||
public bool IsDockingActive => false;
|
||||
public NavigationNode? DockGoal => null;
|
||||
public string DockPhase => string.Empty;
|
||||
public string DockDirection => string.Empty;
|
||||
public int DockRetryCount => 0;
|
||||
public int DockMaxRetries => 0;
|
||||
public int DockWaypointCount => 0;
|
||||
public NavigationNode? DockStartNode => null;
|
||||
public IReadOnlyList<NavigationNode>? DockWaypoints => null;
|
||||
|
||||
private volatile SimulationNavigation? SimNavigation;
|
||||
private RobotNet10.RobotApp.Interfaces.NavigationState? _lastFinishedState;
|
||||
private bool IsSimulation => robotConfiguration.GetSimulationConfig().IsEnable;
|
||||
|
||||
public event Action<RobotNet10.RobotApp.Interfaces.NavigationState>? OnNavigationFinished;
|
||||
|
||||
public void CancelMovement()
|
||||
{
|
||||
if (IsSimulation)
|
||||
{
|
||||
SimNavigation?.CancelMovement();
|
||||
return;
|
||||
}
|
||||
|
||||
navigationIntegrationService.Cancel();
|
||||
}
|
||||
|
||||
public void Move(OrderMsg order, bool hasLoad = false)
|
||||
{
|
||||
_lastFinishedState = null;
|
||||
var nodes = order.Nodes;
|
||||
var edges = order.Edges;
|
||||
|
||||
if (IsSimulation)
|
||||
{
|
||||
if (SimNavigation is not null) throw new NavigationException("The Sim Navigation module is called during operation.");
|
||||
SimNavigation = SimulationNavigationManager.GetNavigation(robotConfiguration.GetRobotPhysicalConfig().NavigationType, serviceProvider);
|
||||
SimNavigation.OnNavigationFinished += NavigationFinished;
|
||||
SimNavigation.Move(order, hasLoad);
|
||||
return;
|
||||
}
|
||||
|
||||
if (nodes.Length == 0)
|
||||
throw new NavigationException("Move failed: nodes list is empty.");
|
||||
|
||||
var target = nodes[^1];
|
||||
var (targetX, targetY, theta) = GetNodePose(target, nodes);
|
||||
var (qz, qw) = ToYawQuaternion(theta);
|
||||
|
||||
// Convert VDA5050 order (from MQTT server) to OrderData and run full graph navigation
|
||||
var orderData = RobotNet10.RobotApp.Navigation.VDA5050ToOrderDataConverter.ToOrderData(nodes, edges, orderMsg: order);
|
||||
if (!navigationIntegrationService.MoveToOrder(orderData, targetX, targetY, 0.0, 0.0, 0.0, qz, qw))
|
||||
throw new NavigationException("Move failed: Navigation C API service is not ready or rejected goal.");
|
||||
}
|
||||
|
||||
public void MoveStraight(double x, double y, bool hasLoad = false, RobotDirection? direction = null)
|
||||
{
|
||||
if (IsSimulation)
|
||||
{
|
||||
if (SimNavigation is not null) throw new NavigationException("The Sim Navigation module is called during operation.");
|
||||
SimNavigation = SimulationNavigationManager.GetNavigation(robotConfiguration.GetRobotPhysicalConfig().NavigationType, serviceProvider);
|
||||
SimNavigation.OnNavigationFinished += NavigationFinished;
|
||||
SimNavigation.MoveStraight(x, y, hasLoad, direction);
|
||||
return;
|
||||
}
|
||||
|
||||
var current = navigationIntegrationService.GetRobotPose2D();
|
||||
var currentX = current?.x ?? 0.0;
|
||||
var currentY = current?.y ?? 0.0;
|
||||
var heading = Math.Atan2(y - currentY, x - currentX);
|
||||
var (qz, qw) = ToYawQuaternion(heading);
|
||||
|
||||
if (!navigationIntegrationService.MoveTo(x, y, 0.0, 0.0, 0.0, qz, qw))
|
||||
throw new NavigationException("MoveStraight failed: Navigation C API service is not ready or rejected goal.");
|
||||
}
|
||||
|
||||
public void Pause()
|
||||
{
|
||||
if (IsSimulation) SimNavigation?.Pause();
|
||||
else navigationIntegrationService.Pause();
|
||||
}
|
||||
|
||||
public void Resume()
|
||||
{
|
||||
if (IsSimulation) SimNavigation?.Resume();
|
||||
else navigationIntegrationService.Resume();
|
||||
}
|
||||
|
||||
public void Rotate(double angle)
|
||||
{
|
||||
_lastFinishedState = null;
|
||||
|
||||
if (IsSimulation)
|
||||
{
|
||||
if (SimNavigation is not null) throw new NavigationException("The Sim Navigation module is called during operation.");
|
||||
SimNavigation = SimulationNavigationManager.GetNavigation(robotConfiguration.GetRobotPhysicalConfig().NavigationType, serviceProvider);
|
||||
SimNavigation.OnNavigationFinished += NavigationFinished;
|
||||
SimNavigation.Rotate(angle * 180 / Math.PI);
|
||||
return;
|
||||
}
|
||||
|
||||
var pose = navigationIntegrationService.GetRobotPose2D();
|
||||
var x = pose?.x ?? 0.0;
|
||||
var y = pose?.y ?? 0.0;
|
||||
var (qz, qw) = ToYawQuaternion(angle);
|
||||
|
||||
if (!navigationIntegrationService.RotateTo(x, y, 0.0, 0.0, 0.0, qz, qw))
|
||||
throw new NavigationException("Rotate failed: Navigation C API service is not ready or rejected goal.");
|
||||
}
|
||||
|
||||
public void DockTo(IDetectSession session, bool hasLoad = false, RobotDirection? direction = null)
|
||||
{
|
||||
_lastFinishedState = null;
|
||||
|
||||
if (IsSimulation)
|
||||
{
|
||||
if (SimNavigation is not null) throw new NavigationException("The Sim Navigation module is called during operation.");
|
||||
SimNavigation = SimulationNavigationManager.GetNavigation(robotConfiguration.GetRobotPhysicalConfig().NavigationType, serviceProvider);
|
||||
SimNavigation.OnNavigationFinished += NavigationFinished;
|
||||
SimNavigation.DockTo(session, hasLoad, direction);
|
||||
return;
|
||||
}
|
||||
|
||||
var goal = session.Goal ?? throw new NavigationException("DockTo failed: session goal is missing.");
|
||||
var markerName = "dock-marker";
|
||||
var p = goal.Pose.Position;
|
||||
var o = goal.Pose.Orientation;
|
||||
|
||||
if (!navigationIntegrationService.DockTo(markerName, p.X, p.Y, p.Z, o.X, o.Y, o.Z, o.W))
|
||||
throw new NavigationException("DockTo failed: Navigation C API service is not ready or rejected goal.");
|
||||
}
|
||||
|
||||
public void RefreshOrder(Node[] nodes, Edge[] edges)
|
||||
{
|
||||
logger.LogWarning("RefreshOrder is not yet implemented for C API navigation path.");
|
||||
}
|
||||
|
||||
public void UpdateOrder(string lastBaseNodeId)
|
||||
{
|
||||
if (IsSimulation)
|
||||
{
|
||||
SimNavigation?.UpdateOrder(lastBaseNodeId);
|
||||
return;
|
||||
}
|
||||
|
||||
logger.LogDebug("UpdateOrder called in C API mode with lastBaseNodeId={LastBaseNodeId}.", lastBaseNodeId);
|
||||
}
|
||||
|
||||
public void SafetyStop()
|
||||
{
|
||||
if (IsSimulation) SimNavigation?.SafetyStop();
|
||||
else navigationIntegrationService.Cancel();
|
||||
}
|
||||
|
||||
public void Refresh()
|
||||
{
|
||||
if (IsSimulation) SimNavigation?.Refresh();
|
||||
}
|
||||
|
||||
private void NavigationFinished(RobotNet10.RobotApp.Interfaces.NavigationState state)
|
||||
{
|
||||
_lastFinishedState = state;
|
||||
OnNavigationFinished?.Invoke(state);
|
||||
|
||||
if (IsSimulation) SimNavigation?.OnNavigationFinished -= NavigationFinished;
|
||||
SimNavigation = null;
|
||||
}
|
||||
|
||||
public void SetSpeed(double speed)
|
||||
{
|
||||
if (IsSimulation) SimNavigation?.SetSpeed(speed);
|
||||
else
|
||||
{
|
||||
logger.LogInformation("SetSpeed called with speed={Speed}", speed);
|
||||
if (!navigationIntegrationService.SetTwistLinear(speed, 0.0, 0.0))
|
||||
throw new NavigationException($"SetSpeed failed: unable to set linear velocity to {speed} via Navigation C API.");
|
||||
}
|
||||
}
|
||||
|
||||
public void Start()
|
||||
{
|
||||
IsReady = IsSimulation || navigationIntegrationService.IsInitialized;
|
||||
if (!IsSimulation && !_navResultSubscribed)
|
||||
{
|
||||
navigationIntegrationService.OnNavigationResult += OnNavigationResultReceived;
|
||||
_navResultSubscribed = true;
|
||||
}
|
||||
}
|
||||
|
||||
private void OnNavigationResultReceived(RobotNet10.RobotApp.Navigation.NavigationState state)
|
||||
{
|
||||
var mapped = MapNavigationState(state);
|
||||
NavigationFinished(mapped);
|
||||
}
|
||||
|
||||
public void Stop()
|
||||
{
|
||||
if (SimNavigation is not null)
|
||||
{
|
||||
SimNavigation.CancelMovement();
|
||||
}
|
||||
else
|
||||
{
|
||||
navigationIntegrationService.Cancel();
|
||||
}
|
||||
}
|
||||
|
||||
private static (double qz, double qw) ToYawQuaternion(double yaw)
|
||||
{
|
||||
var half = yaw / 2.0;
|
||||
return (Math.Sin(half), Math.Cos(half));
|
||||
}
|
||||
|
||||
private static (double x, double y, double theta) GetNodePose(Node target, Node[] allNodes)
|
||||
{
|
||||
var (x, y, thetaOpt) = ExtractNodePosition(target);
|
||||
if (thetaOpt.HasValue)
|
||||
return (x, y, thetaOpt.Value);
|
||||
|
||||
if (allNodes.Length >= 2)
|
||||
{
|
||||
var (prevX, prevY, _) = ExtractNodePosition(allNodes[^2]);
|
||||
return (x, y, Math.Atan2(y - prevY, x - prevX));
|
||||
}
|
||||
|
||||
return (x, y, 0.0);
|
||||
}
|
||||
|
||||
private static (double x, double y, double? theta) ExtractNodePosition(Node node)
|
||||
{
|
||||
// VDA5050 Node may store coordinates in NodePosition, while legacy models may use X/Y/Theta directly.
|
||||
var nodeType = node.GetType();
|
||||
|
||||
var nodePosProp = nodeType.GetProperty("NodePosition");
|
||||
if (nodePosProp?.GetValue(node) is object nodePos)
|
||||
{
|
||||
var posType = nodePos.GetType();
|
||||
var xObj = posType.GetProperty("X")?.GetValue(nodePos);
|
||||
var yObj = posType.GetProperty("Y")?.GetValue(nodePos);
|
||||
var thetaObj = posType.GetProperty("Theta")?.GetValue(nodePos);
|
||||
|
||||
return (
|
||||
xObj is null ? 0.0 : Convert.ToDouble(xObj),
|
||||
yObj is null ? 0.0 : Convert.ToDouble(yObj),
|
||||
thetaObj is null ? null : Convert.ToDouble(thetaObj));
|
||||
}
|
||||
|
||||
var xLegacy = nodeType.GetProperty("X")?.GetValue(node);
|
||||
var yLegacy = nodeType.GetProperty("Y")?.GetValue(node);
|
||||
var thetaLegacy = nodeType.GetProperty("Theta")?.GetValue(node);
|
||||
|
||||
return (
|
||||
xLegacy is null ? 0.0 : Convert.ToDouble(xLegacy),
|
||||
yLegacy is null ? 0.0 : Convert.ToDouble(yLegacy),
|
||||
thetaLegacy is null ? null : Convert.ToDouble(thetaLegacy));
|
||||
}
|
||||
|
||||
private static RobotNet10.RobotApp.Interfaces.NavigationState MapNavigationState(RobotNet10.RobotApp.Navigation.NavigationState? state)
|
||||
{
|
||||
return state switch
|
||||
{
|
||||
RobotNet10.RobotApp.Navigation.NavigationState.Pending => RobotNet10.RobotApp.Interfaces.NavigationState.Waiting,
|
||||
RobotNet10.RobotApp.Navigation.NavigationState.Planning => RobotNet10.RobotApp.Interfaces.NavigationState.Initializing,
|
||||
RobotNet10.RobotApp.Navigation.NavigationState.Active => RobotNet10.RobotApp.Interfaces.NavigationState.Moving,
|
||||
RobotNet10.RobotApp.Navigation.NavigationState.Controlling => RobotNet10.RobotApp.Interfaces.NavigationState.Moving,
|
||||
RobotNet10.RobotApp.Navigation.NavigationState.Clearing => RobotNet10.RobotApp.Interfaces.NavigationState.Moving,
|
||||
RobotNet10.RobotApp.Navigation.NavigationState.Succeeded => RobotNet10.RobotApp.Interfaces.NavigationState.Completed,
|
||||
RobotNet10.RobotApp.Navigation.NavigationState.Paused => RobotNet10.RobotApp.Interfaces.NavigationState.Paused,
|
||||
RobotNet10.RobotApp.Navigation.NavigationState.Preempted => RobotNet10.RobotApp.Interfaces.NavigationState.Canceled,
|
||||
RobotNet10.RobotApp.Navigation.NavigationState.Recalled => RobotNet10.RobotApp.Interfaces.NavigationState.Canceled,
|
||||
RobotNet10.RobotApp.Navigation.NavigationState.Rejected => RobotNet10.RobotApp.Interfaces.NavigationState.Error,
|
||||
RobotNet10.RobotApp.Navigation.NavigationState.Aborted => RobotNet10.RobotApp.Interfaces.NavigationState.Error,
|
||||
RobotNet10.RobotApp.Navigation.NavigationState.Lost => RobotNet10.RobotApp.Interfaces.NavigationState.Error,
|
||||
_ => RobotNet10.RobotApp.Interfaces.NavigationState.Idle
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
namespace RobotNet10.RobotApp.Services.Robot;
|
||||
|
||||
/// <summary>
|
||||
/// Modbus coil addresses aligned with PLC mapping document.
|
||||
/// Input (read): 2848-2879 (M800-M821 sensors, M825-M831 speed SLS).
|
||||
/// Output (write): 2948-2977 (M900-M909 state, M915-M917 operation, M920-M929 actions).
|
||||
/// </summary>
|
||||
public partial class RobotPlcController
|
||||
{
|
||||
// === Input coils: ReadOnlyStartAddress 2848 (0x0b20), offsets vs 2848 ===
|
||||
public static readonly ushort ReadOnlyStartAddress = 0x0b20; // 2848, M800
|
||||
public static readonly ushort EmergencyOffsetAddress = 0; // M800 EMC
|
||||
public static readonly ushort BumperOffsetAddress = 1; // M801 Bumper
|
||||
|
||||
public static readonly ushort LidarFrontProtectFieldOffsetAddress = 2; // M802 Lidar NS3-FR
|
||||
public static readonly ushort LidarBackProtectFieldOffsetAddress = 3; // M803 Lidar NS3-RR
|
||||
public static readonly ushort LidarFrontTimProtectFieldOffsetAddress = 4; // M804 Lidar TIM718S-FR
|
||||
|
||||
public static readonly ushort LiftedUpOffsetAddress = 5; // M805 Lift up limit
|
||||
public static readonly ushort LiftedDownOffsetAddress = 6; // M806 Lift down limit
|
||||
public static readonly ushort LiftHomeOffsetAddress = 7; // M807 Rotate homing
|
||||
|
||||
public static readonly ushort LeftMotorReadyOffsetAddress = 8; // M808
|
||||
public static readonly ushort RightMotorReadyOffsetAddress = 9; // M809
|
||||
public static readonly ushort LiftMotorReadyOffsetAddress = 10; // M810
|
||||
|
||||
public static readonly ushort SwitchLockOffsetAddress = 11; // M811 Lock
|
||||
public static readonly ushort SwitchAutoOffsetAddress = 12; // M812 Auto
|
||||
public static readonly ushort SwitchManualOffsetAddress = 13; // M813 Manual
|
||||
|
||||
public static readonly ushort StartButtonOffsetAddress = 14; // M814 Start
|
||||
public static readonly ushort ResetButtonOffsetAddress = 15; // M815 Reset
|
||||
public static readonly ushort StopButtonOffsetAddress = 16; // M816 Stop
|
||||
|
||||
public static readonly ushort HasLoadOffsetAddress = 17; // M817 Báo có tải
|
||||
public static readonly ushort EnabledChargerOffsetAddress = 18; // M818 PLC charging contact
|
||||
public static readonly ushort ResponseChargingOffsetAddress = 19; // M819 Response Charging
|
||||
public static readonly ushort MutedBaseOffsetAddress = 20; // M820 Response Muted Base
|
||||
public static readonly ushort MutedLoadOffsetAddress = 21; // M821 Response Muted Load
|
||||
|
||||
// Speed SLS: 2873-2879 (M825-M831)
|
||||
public static readonly ushort SpeedLimitReadAddress = 0x0b39; // 2873, M825
|
||||
public static readonly ushort SpeedRange = 7;
|
||||
public static readonly ushort SpeedVerySlowOffetAddress = 0; // M825 0.15
|
||||
public static readonly ushort SpeedSlowOffetAddress = 1; // M826 0.25
|
||||
public static readonly ushort SpeedNormalOffetAddress = 2; // M827 0.55
|
||||
public static readonly ushort SpeedMediumOffetAddress = 3; // M828 0.9
|
||||
public static readonly ushort SpeedOptimalOffetAddress = 4; // M829 1.28
|
||||
public static readonly ushort SpeedFastOffetAddress = 5; // M830 1.6
|
||||
public static readonly ushort SpeedVeryFastOffetAddress = 6; // M831 1.9 Overspeed
|
||||
|
||||
// Robot state: 2948-2957 (M900-M909 INIT, PAUSE, IDLE, PROCESSING, DOCKING, MAINTENANCE, MANUAL, OVERRIDE, CHARGING, Error)
|
||||
public static readonly ushort RobotStateWriteAddress = 0x0b84; // 2948, M900
|
||||
public static readonly bool[] RobotInitState = [true, false, false, false, false, false, false, false, false, false];
|
||||
public static readonly bool[] RobotPauseState = [false, true, false, false, false, false, false, false, false, false];
|
||||
public static readonly bool[] RobotIdleState = [false, false, true, false, false, false, false, false, false, false];
|
||||
public static readonly bool[] RobotProccessingState = [false, false, false, true, false, false, false, false, false, false];
|
||||
public static readonly bool[] RobotDockingState = [false, false, false, false, true, false, false, false, false, false];
|
||||
public static readonly bool[] RobotMaintenanceState = [false, false, false, false, false, true, false, false, false, false];
|
||||
public static readonly bool[] RobotManualState = [false, false, false, false, false, false, true, false, false, false];
|
||||
public static readonly bool[] RobotOverrideState = [false, false, false, false, false, false, false, true, false, false];
|
||||
public static readonly bool[] RobotCharingState = [false, false, false, false, false, false, false, false, true, false];
|
||||
public static readonly bool[] RobotErrorState = [false, false, false, false, false, false, false, false, false, true];
|
||||
|
||||
// Movement/lift: 2963-2965 (M915 Moving, M916 Lifting, M917 Rotating)
|
||||
public static readonly ushort RobotOperationWriteAddress = 0x0b93; // 2963, M915
|
||||
public static readonly bool[] RobotExecuteClearState = [false, false, false];
|
||||
public static readonly bool[] RobotExecuteMoveState = [true, false, false];
|
||||
public static readonly bool[] RobotExecuteLiftingState = [false, true, false];
|
||||
public static readonly bool[] RobotExecuteLiftRotatingState = [false, false, true];
|
||||
|
||||
// M918 Bật đèn — coil 2966 (TCP address)
|
||||
public static readonly ushort SetLightOnAddress = 0x0b96; // 2966, M918 Bật đèn
|
||||
|
||||
// Actions: 2968-2977 (M920-M929)
|
||||
public static readonly ushort EnableChargerAddress = 0x0b98; // 2968 M920 Bắt tiếp điểm
|
||||
public static readonly ushort SetHorizontalLoadAddress = 0x0b99; // 2969 M921 Báo tải nằm ngang
|
||||
public static readonly ushort SetMutedBaseAddress = 0x0b9a; // 2970 M922 Set Muted Base
|
||||
public static readonly ushort SetMutedLoadAddress = 0x0b9b; // 2971 M923 Muted Load
|
||||
|
||||
public static readonly ushort SetRFModeAddress = 0x0b9c; // 2972 M924-M926 RF Default/Maintenance/Override
|
||||
public static readonly bool[] RFModeNone = [false, false, false];
|
||||
public static readonly bool[] RFModeDefault = [true, false, false];
|
||||
public static readonly bool[] RFModeMaintenance = [false, true, false];
|
||||
public static readonly bool[] RFModeOverride = [false, false, true];
|
||||
|
||||
|
||||
public static readonly ushort SetHasLoadAddress = 0x0b9f; // 2975 M927 Báo có tải
|
||||
public static readonly ushort SetRFEStopAddress = 0x0ba0; // 2976 M928 EMC RF Remote
|
||||
public static readonly ushort SetBatteryLowAddress = 0x0ba1; // 2977 M929 Pin yếu
|
||||
|
||||
/// <summary>Ghi M815 Alarm Reset xuống PLC — cùng coil với nút M815 (2848+15=2863), pulse ON rồi OFF để PLC alarm reset.</summary>
|
||||
public static readonly ushort AlarmResetM815WriteAddress = (ushort)(ReadOnlyStartAddress + ResetButtonOffsetAddress); // 2863 M815
|
||||
|
||||
// Hướng di chuyển: truyền xuống PLC — tiến M931, lùi M932, không đi thì cả 2 off
|
||||
public static readonly ushort DirectionForwardAddress = 0x0ba3; // 2979 M931 Tiến
|
||||
public static readonly ushort DirectionBackwardAddress = 0x0ba4; // 2980 M932 Lùi
|
||||
|
||||
// Lift module: Homing (pulse), Velocity (up/down coils), Position (holding register 32-bit)
|
||||
public static readonly ushort LiftHomingAddress = 0x0ba5; // M933 Lift homing (pulse)
|
||||
public static readonly ushort LiftVelocityUpAddress = 0x0ba6; // M934 Lift lên (velocity)
|
||||
public static readonly ushort LiftVelocityDownAddress = 0x0ba7; // M935 Lift xuống (velocity)
|
||||
public static readonly ushort LiftTargetPositionRegister = 0x0bc0; // D register: target position (32-bit = 2 registers). 10000 = 0.01m
|
||||
public static readonly ushort LiftGoToPositionAddress = 0x0ba8; // M936 Trigger di chuyển đến vị trí (pulse)
|
||||
}
|
||||
@@ -0,0 +1,466 @@
|
||||
using RobotNet.VDA5050.Type;
|
||||
using RobotNet10.RobotApp.Devices;
|
||||
using RobotNet10.RobotApp.Interfaces;
|
||||
using RobotNet10.RobotApp.Services.ConfigManager;
|
||||
|
||||
namespace RobotNet10.RobotApp.Services.Robot;
|
||||
|
||||
public partial class RobotPlcController(IRobotConfiguration RobotConfiguration, IDeviceProvider DeviceProvider, Logger<RobotPlcController> Logger) : IPlcController
|
||||
{
|
||||
public bool IsReady { get; private set; } = false;
|
||||
public bool IsDisconected => !IsSimulation && (ModbusTcpDevice is null || !ModbusTcpDevice.IsConnected);
|
||||
public event Action<SafetySpeed>? OnSafetySpeedChanged;
|
||||
public event Action<OperatingMode>? OnPeripheralModeChanged;
|
||||
public event Action<PeripheralButton>? OnButtonPressed;
|
||||
public event Action<StopStateType>? OnStop;
|
||||
|
||||
private bool IsSimulation => RobotConfiguration.GetSimulationConfig().IsEnable;
|
||||
|
||||
private IModbusTcpDevice? ModbusTcpDevice;
|
||||
|
||||
// Edge detection tracking fields
|
||||
private StopStateType _lastStopState = StopStateType.None;
|
||||
private bool _lastButtonStart, _lastButtonReset, _lastButtonStop;
|
||||
|
||||
public async Task Start(CancellationToken cancellationToken)
|
||||
{
|
||||
LidarBackProtectField = true;
|
||||
LidarFrontProtectField = true;
|
||||
if (IsSimulation)
|
||||
{
|
||||
PeripheralMode = OperatingMode.AUTOMATIC;
|
||||
}
|
||||
else if (ModbusTcpDevice is null)
|
||||
{
|
||||
while (!cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
if (DeviceProvider.AreDevicesLoaded) break;
|
||||
await Task.Delay(500);
|
||||
}
|
||||
|
||||
var device = DeviceProvider.GetDevice("plc-001");
|
||||
if (device is IModbusTcpDevice modbusDevice)
|
||||
{
|
||||
ModbusTcpDevice = modbusDevice;
|
||||
ModbusTcpDevice.DataRegisterChanged += ModbusDataChanged;
|
||||
while (!cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
if (modbusDevice.IsConnected) break;
|
||||
await Task.Delay(500);
|
||||
}
|
||||
}
|
||||
else return;
|
||||
}
|
||||
IsReady = true;
|
||||
}
|
||||
|
||||
public void Stop()
|
||||
{
|
||||
|
||||
ModbusTcpDevice?.DataRegisterChanged -= ModbusDataChanged;
|
||||
ModbusTcpDevice = null;
|
||||
|
||||
// Reset edge detection state
|
||||
_lastStopState = StopStateType.None;
|
||||
_lastButtonStart = false;
|
||||
_lastButtonReset = false;
|
||||
_lastButtonStop = false;
|
||||
|
||||
IsReady = false;
|
||||
}
|
||||
|
||||
private void ModbusDataChanged(ModbusRegisterType type)
|
||||
{
|
||||
if (type == ModbusRegisterType.Coil)
|
||||
{
|
||||
// Read-only data from PLC
|
||||
ReadSafetyProtect();
|
||||
ReadSafetySpeed();
|
||||
ReadButton();
|
||||
ReadSwitch();
|
||||
ReadLiftState();
|
||||
ReadMotorState();
|
||||
ReadOtherState();
|
||||
}
|
||||
}
|
||||
|
||||
public void SetHorizontalLoad(bool value)
|
||||
{
|
||||
if (IsSimulation) return;
|
||||
if (ModbusTcpDevice is null) throw new InvalidOperationException("PLC Controller is not connected. Ensure Start() completed successfully.");
|
||||
var write = ModbusTcpDevice.WriteCoilAsync(SetHorizontalLoadAddress, value, CancellationToken.None);
|
||||
write.Wait();
|
||||
}
|
||||
|
||||
public void SetMutedBase(bool muted)
|
||||
{
|
||||
if (IsSimulation) return;
|
||||
if (ModbusTcpDevice is null) throw new InvalidOperationException("PLC Controller is not connected. Ensure Start() completed successfully.");
|
||||
var write = ModbusTcpDevice.WriteCoilAsync(SetMutedBaseAddress, muted, CancellationToken.None);
|
||||
write.Wait();
|
||||
}
|
||||
|
||||
public void SetMutedLoad(bool muted)
|
||||
{
|
||||
if (IsSimulation) return;
|
||||
if (ModbusTcpDevice is null) throw new InvalidOperationException("PLC Controller is not connected. Ensure Start() completed successfully.");
|
||||
var write = ModbusTcpDevice.WriteCoilAsync(SetMutedLoadAddress, muted, CancellationToken.None);
|
||||
write.Wait();
|
||||
}
|
||||
|
||||
/// <summary>Bật/tắt đèn — ghi coil M918 (TCP address 2966).</summary>
|
||||
public void SetLightOn(bool value)
|
||||
{
|
||||
if (IsSimulation) return;
|
||||
if (ModbusTcpDevice is null) throw new InvalidOperationException("PLC Controller is not connected. Ensure Start() completed successfully.");
|
||||
var write = ModbusTcpDevice.WriteCoilAsync(SetLightOnAddress, value, CancellationToken.None);
|
||||
write.Wait();
|
||||
}
|
||||
|
||||
public void SetOperationState(OperationState state)
|
||||
{
|
||||
if (IsSimulation) return;
|
||||
if (ModbusTcpDevice is null) throw new InvalidOperationException("PLC Controller is not connected. Ensure Start() completed successfully.");
|
||||
var write = Task.Run(async () =>
|
||||
{
|
||||
switch (state)
|
||||
{
|
||||
case OperationState.Move:
|
||||
await ModbusTcpDevice.WriteCoilsAsync(RobotOperationWriteAddress, RobotExecuteMoveState);
|
||||
break;
|
||||
case OperationState.Lifting:
|
||||
await ModbusTcpDevice.WriteCoilsAsync(RobotOperationWriteAddress, RobotExecuteLiftingState);
|
||||
break;
|
||||
case OperationState.LiftRotating:
|
||||
await ModbusTcpDevice.WriteCoilsAsync(RobotOperationWriteAddress, RobotExecuteLiftRotatingState);
|
||||
break;
|
||||
case OperationState.None:
|
||||
default:
|
||||
await ModbusTcpDevice.WriteCoilsAsync(RobotOperationWriteAddress, RobotExecuteClearState);
|
||||
break;
|
||||
}
|
||||
});
|
||||
write.Wait();
|
||||
}
|
||||
|
||||
public void SetSystemState(SystemState state)
|
||||
{
|
||||
if (IsSimulation) return;
|
||||
if (ModbusTcpDevice is null) throw new InvalidOperationException("PLC Controller is not connected. Ensure Start() completed successfully.");
|
||||
var write = Task.Run(async () =>
|
||||
{
|
||||
switch (state)
|
||||
{
|
||||
case SystemState.INIT:
|
||||
await ModbusTcpDevice.WriteCoilsAsync(RobotStateWriteAddress, RobotInitState);
|
||||
break;
|
||||
case SystemState.PAUSED:
|
||||
await ModbusTcpDevice.WriteCoilsAsync(RobotStateWriteAddress, RobotPauseState);
|
||||
break;
|
||||
case SystemState.IDLE:
|
||||
await ModbusTcpDevice.WriteCoilsAsync(RobotStateWriteAddress, RobotIdleState);
|
||||
break;
|
||||
case SystemState.PROCCESSING:
|
||||
await ModbusTcpDevice.WriteCoilsAsync(RobotStateWriteAddress, RobotProccessingState);
|
||||
break;
|
||||
case SystemState.DOCKING:
|
||||
await ModbusTcpDevice.WriteCoilsAsync(RobotStateWriteAddress, RobotDockingState);
|
||||
break;
|
||||
case SystemState.MAINTENANCE:
|
||||
await ModbusTcpDevice.WriteCoilsAsync(RobotStateWriteAddress, RobotMaintenanceState);
|
||||
break;
|
||||
case SystemState.MANUAL:
|
||||
await ModbusTcpDevice.WriteCoilsAsync(RobotStateWriteAddress, RobotManualState);
|
||||
break;
|
||||
case SystemState.OVERRIDE:
|
||||
await ModbusTcpDevice.WriteCoilsAsync(RobotStateWriteAddress, RobotOverrideState);
|
||||
break;
|
||||
case SystemState.CHARGING:
|
||||
await ModbusTcpDevice.WriteCoilsAsync(RobotStateWriteAddress, RobotCharingState);
|
||||
break;
|
||||
case SystemState.ERROR:
|
||||
await ModbusTcpDevice.WriteCoilsAsync(RobotStateWriteAddress, RobotErrorState);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
});
|
||||
write.Wait();
|
||||
}
|
||||
|
||||
public void SetEnableCharger(bool value)
|
||||
{
|
||||
if (IsSimulation) return;
|
||||
if (ModbusTcpDevice is null) throw new InvalidOperationException("PLC Controller is not connected. Ensure Start() completed successfully.");
|
||||
var write = ModbusTcpDevice.WriteCoilAsync(EnableChargerAddress, value, CancellationToken.None);
|
||||
write.Wait();
|
||||
}
|
||||
|
||||
public void SetRFMode(RFMode mode)
|
||||
{
|
||||
if (IsSimulation) return;
|
||||
if (ModbusTcpDevice is null) throw new InvalidOperationException("PLC Controller is not connected. Ensure Start() completed successfully.");
|
||||
var write = Task.Run(async () =>
|
||||
{
|
||||
switch (mode)
|
||||
{
|
||||
case RFMode.Default:
|
||||
await ModbusTcpDevice.WriteCoilsAsync(SetRFModeAddress, RFModeDefault);
|
||||
break;
|
||||
case RFMode.Maintenance:
|
||||
await ModbusTcpDevice.WriteCoilsAsync(SetRFModeAddress, RFModeMaintenance);
|
||||
break;
|
||||
case RFMode.Override:
|
||||
await ModbusTcpDevice.WriteCoilsAsync(SetRFModeAddress, RFModeOverride);
|
||||
break;
|
||||
case RFMode.None:
|
||||
default:
|
||||
await ModbusTcpDevice.WriteCoilsAsync(SetRFModeAddress, RFModeNone);
|
||||
break;
|
||||
}
|
||||
});
|
||||
write.Wait();
|
||||
}
|
||||
|
||||
public void SetHasLoad(bool hasLoad)
|
||||
{
|
||||
if (IsSimulation) return;
|
||||
if (ModbusTcpDevice is null) throw new InvalidOperationException("PLC Controller is not connected. Ensure Start() completed successfully.");
|
||||
var write = ModbusTcpDevice.WriteCoilAsync(SetHasLoadAddress, hasLoad, CancellationToken.None);
|
||||
write.Wait();
|
||||
}
|
||||
|
||||
public void SetRFEStop(bool stop)
|
||||
{
|
||||
if (IsSimulation) return;
|
||||
if (ModbusTcpDevice is null) throw new InvalidOperationException("PLC Controller is not connected. Ensure Start() completed successfully.");
|
||||
var write = ModbusTcpDevice.WriteCoilAsync(SetRFEStopAddress, stop, CancellationToken.None);
|
||||
write.Wait();
|
||||
}
|
||||
|
||||
public void SetBatteryLow(bool value)
|
||||
{
|
||||
if (IsSimulation) return;
|
||||
if (ModbusTcpDevice is null) throw new InvalidOperationException("PLC Controller is not connected. Ensure Start() completed successfully.");
|
||||
var write = ModbusTcpDevice.WriteCoilAsync(SetBatteryLowAddress, value, CancellationToken.None);
|
||||
write.Wait();
|
||||
}
|
||||
|
||||
/// <summary>Ghi hướng di chuyển xuống PLC: tiến M931, lùi M932; không đi thì cả hai off.</summary>
|
||||
public void SetDirectionForwardBackward(bool forward, bool backward)
|
||||
{
|
||||
if (IsSimulation) return;
|
||||
if (ModbusTcpDevice is null) return;
|
||||
// Tiến: M931 on, M932 off. Lùi: M931 off, M932 on. Không đi: cả hai off (không bao giờ cả hai on)
|
||||
var w1 = ModbusTcpDevice.WriteCoilAsync(DirectionForwardAddress, forward, CancellationToken.None);
|
||||
var w2 = ModbusTcpDevice.WriteCoilAsync(DirectionBackwardAddress, backward, CancellationToken.None);
|
||||
Task.WaitAll(w1, w2);
|
||||
}
|
||||
|
||||
/// <summary>Ghi M815 Alarm Reset xuống PLC (pulse) — gửi ngay ON rồi OFF, cùng coil như khi bấm M815 trên device.</summary>
|
||||
public void WriteAlarmResetM815()
|
||||
{
|
||||
if (IsSimulation) return;
|
||||
if (ModbusTcpDevice is null) return;
|
||||
ushort addr = AlarmResetM815WriteAddress;
|
||||
ModbusTcpDevice.WriteCoilImmediateAsync(addr, true, CancellationToken.None).GetAwaiter().GetResult();
|
||||
Thread.Sleep(150);
|
||||
ModbusTcpDevice.WriteCoilImmediateAsync(addr, false, CancellationToken.None).GetAwaiter().GetResult();
|
||||
Logger.Info($"WriteAlarmResetM815: pulsed coil {addr} (M815) ON -> OFF");
|
||||
}
|
||||
|
||||
/// <summary>Lift: Homing — pulse coil M933.</summary>
|
||||
public void LiftHoming()
|
||||
{
|
||||
if (IsSimulation) return;
|
||||
if (ModbusTcpDevice is null) throw new InvalidOperationException("PLC Controller is not connected.");
|
||||
ushort addr = LiftHomingAddress;
|
||||
ModbusTcpDevice.WriteCoilImmediateAsync(addr, true, CancellationToken.None).GetAwaiter().GetResult();
|
||||
Thread.Sleep(150);
|
||||
ModbusTcpDevice.WriteCoilImmediateAsync(addr, false, CancellationToken.None).GetAwaiter().GetResult();
|
||||
Logger.Info($"LiftHoming: pulsed coil {addr} (M933) ON -> OFF");
|
||||
}
|
||||
|
||||
/// <summary>Lift: Điều khiển velocity — lên (M934), xuống (M935); cả hai off = dừng.</summary>
|
||||
public void SetLiftVelocity(bool up, bool down)
|
||||
{
|
||||
if (IsSimulation) return;
|
||||
if (ModbusTcpDevice is null) return;
|
||||
var w1 = ModbusTcpDevice.WriteCoilAsync(LiftVelocityUpAddress, up, CancellationToken.None);
|
||||
var w2 = ModbusTcpDevice.WriteCoilAsync(LiftVelocityDownAddress, down, CancellationToken.None);
|
||||
Task.WaitAll(w1, w2);
|
||||
}
|
||||
|
||||
/// <summary>Lift: Ghi vị trí đích (10000 = 0.01m) vào 2 holding registers rồi pulse M936.</summary>
|
||||
public void SetLiftPositionAndGo(int position)
|
||||
{
|
||||
if (IsSimulation) return;
|
||||
if (ModbusTcpDevice is null) throw new InvalidOperationException("PLC Controller is not connected.");
|
||||
var high = (ushort)((position >> 16) & 0xFFFF);
|
||||
var low = (ushort)(position & 0xFFFF);
|
||||
ModbusTcpDevice.WriteHoldingRegistersAsync(LiftTargetPositionRegister, [high, low], CancellationToken.None).GetAwaiter().GetResult();
|
||||
ModbusTcpDevice.WriteCoilImmediateAsync(LiftGoToPositionAddress, true, CancellationToken.None).GetAwaiter().GetResult();
|
||||
Thread.Sleep(150);
|
||||
ModbusTcpDevice.WriteCoilImmediateAsync(LiftGoToPositionAddress, false, CancellationToken.None).GetAwaiter().GetResult();
|
||||
Logger.Info($"SetLiftPositionAndGo: position={position} (10000=0.01m), pulsed M936");
|
||||
}
|
||||
|
||||
private static SafetySpeed ReadSpeed(bool[] flag)
|
||||
{
|
||||
if (flag.Length < 7) return SafetySpeed.Very_Slow;
|
||||
if (flag[0]) return SafetySpeed.Very_Slow; // giới hạn chặt nhất
|
||||
if (flag[1]) return SafetySpeed.Slow;
|
||||
if (flag[2]) return SafetySpeed.Normal;
|
||||
if (flag[3]) return SafetySpeed.Medium;
|
||||
if (flag[4]) return SafetySpeed.Optimal;
|
||||
if (flag[5]) return SafetySpeed.Fast;
|
||||
if (flag[6]) return SafetySpeed.Very_Fast;
|
||||
|
||||
return SafetySpeed.Very_Fast; // không có giới hạn nào
|
||||
}
|
||||
|
||||
private void ReadSafetySpeed()
|
||||
{
|
||||
var device = ModbusTcpDevice;
|
||||
if (device is null) return;
|
||||
|
||||
bool[] speed = device.ReadCoils(SpeedLimitReadAddress, SpeedRange);
|
||||
if (speed.Length == SpeedRange)
|
||||
{
|
||||
var activeSpeed = ReadSpeed(speed);
|
||||
if (activeSpeed != SafetySpeed)
|
||||
{
|
||||
SafetySpeed = activeSpeed;
|
||||
OnSafetySpeedChanged?.Invoke(activeSpeed);
|
||||
}
|
||||
}
|
||||
else Logger.Warning($"Read Safety Speed is failed: data length {speed.Length} is wrong.");
|
||||
}
|
||||
|
||||
private void ReadButton()
|
||||
{
|
||||
var device = ModbusTcpDevice;
|
||||
if (device is null) return;
|
||||
|
||||
bool[] buttons = device.ReadCoils((ushort)(ReadOnlyStartAddress + StartButtonOffsetAddress), 3);
|
||||
if (buttons.Length == 3)
|
||||
{
|
||||
var newStart = buttons[0];
|
||||
var newReset = buttons[1];
|
||||
var newStop = buttons[2];
|
||||
|
||||
// Rising edge detection - only fire when button state changes from false to true
|
||||
if (newStart && !_lastButtonStart) OnButtonPressed?.Invoke(PeripheralButton.Start);
|
||||
if (newReset && !_lastButtonReset) OnButtonPressed?.Invoke(PeripheralButton.Reset);
|
||||
if (newStop && !_lastButtonStop) OnButtonPressed?.Invoke(PeripheralButton.Stop);
|
||||
|
||||
// Update tracking state
|
||||
_lastButtonStart = newStart;
|
||||
_lastButtonReset = newReset;
|
||||
_lastButtonStop = newStop;
|
||||
|
||||
// Update public properties
|
||||
ButtonStart = newStart;
|
||||
ButtonReset = newReset;
|
||||
ButtonStop = newStop;
|
||||
}
|
||||
else Logger.Warning($"Read button is failed: data length {buttons.Length} is wrong.");
|
||||
}
|
||||
|
||||
private void ReadSwitch()
|
||||
{
|
||||
var device = ModbusTcpDevice;
|
||||
if (device is null) return;
|
||||
|
||||
bool[] switchs = device.ReadCoils((ushort)(ReadOnlyStartAddress + SwitchLockOffsetAddress), 3);
|
||||
if (switchs.Length == 3)
|
||||
{
|
||||
var oldMode = PeripheralMode;
|
||||
if (switchs[0])
|
||||
{
|
||||
PeripheralMode = OperatingMode.SERVICE;
|
||||
}
|
||||
else if (switchs[1])
|
||||
{
|
||||
PeripheralMode = OperatingMode.AUTOMATIC;
|
||||
}
|
||||
else if (switchs[2])
|
||||
{
|
||||
PeripheralMode = OperatingMode.MANUAL;
|
||||
}
|
||||
if (oldMode != PeripheralMode) OnPeripheralModeChanged?.Invoke(PeripheralMode);
|
||||
}
|
||||
else Logger.Warning($"Read switch mode is failed: data length {switchs.Length} is wrong.");
|
||||
}
|
||||
|
||||
private void ReadSafetyProtect()
|
||||
{
|
||||
var device = ModbusTcpDevice;
|
||||
if (device is null) return;
|
||||
|
||||
bool[] sensors = device.ReadCoils((ushort)(ReadOnlyStartAddress + EmergencyOffsetAddress), 5);
|
||||
if (sensors.Length == 5)
|
||||
{
|
||||
Emergency = sensors[0];
|
||||
Bumper = sensors[1];
|
||||
LidarFrontProtectField = sensors[2];
|
||||
LidarBackProtectField = sensors[3];
|
||||
LidarFrontTimProtectField = sensors[4];
|
||||
|
||||
// Determine current stop state
|
||||
StopStateType currentState;
|
||||
if (Emergency) currentState = StopStateType.EMC;
|
||||
else if (Bumper) currentState = StopStateType.Bumper;
|
||||
else currentState = StopStateType.None;
|
||||
|
||||
// Only fire event when state actually changes
|
||||
if (currentState != _lastStopState)
|
||||
{
|
||||
_lastStopState = currentState;
|
||||
OnStop?.Invoke(currentState);
|
||||
}
|
||||
}
|
||||
else Logger.Warning($"Read safety protect is failed: data length {sensors.Length} is wrong.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Update lift state from Modbus cache
|
||||
/// </summary>
|
||||
private void ReadLiftState()
|
||||
{
|
||||
var device = ModbusTcpDevice;
|
||||
if (device is null) return;
|
||||
|
||||
LiftedUp = device.ReadCoil((ushort)(ReadOnlyStartAddress + LiftedUpOffsetAddress));
|
||||
LiftedDown = device.ReadCoil((ushort)(ReadOnlyStartAddress + LiftedDownOffsetAddress));
|
||||
LiftHome = device.ReadCoil((ushort)(ReadOnlyStartAddress + LiftHomeOffsetAddress));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Update motor ready state from Modbus cache
|
||||
/// </summary>
|
||||
private void ReadMotorState()
|
||||
{
|
||||
var device = ModbusTcpDevice;
|
||||
if (device is null) return;
|
||||
|
||||
LeftMotorReady = device.ReadCoil((ushort)(ReadOnlyStartAddress + LeftMotorReadyOffsetAddress));
|
||||
RightMotorReady = device.ReadCoil((ushort)(ReadOnlyStartAddress + RightMotorReadyOffsetAddress));
|
||||
LiftMotorReady = device.ReadCoil((ushort)(ReadOnlyStartAddress + LiftMotorReadyOffsetAddress));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Update other state from Modbus cache
|
||||
/// </summary>
|
||||
private void ReadOtherState()
|
||||
{
|
||||
var device = ModbusTcpDevice;
|
||||
if (device is null) return;
|
||||
|
||||
HasLoad = device.ReadCoil((ushort)(ReadOnlyStartAddress + HasLoadOffsetAddress));
|
||||
EnabledCharger = device.ReadCoil((ushort)(ReadOnlyStartAddress + EnabledChargerOffsetAddress));
|
||||
Charging = device.ReadCoil((ushort)(ReadOnlyStartAddress + ResponseChargingOffsetAddress));
|
||||
MutedBase = device.ReadCoil((ushort)(ReadOnlyStartAddress + MutedBaseOffsetAddress));
|
||||
MutedLoad = device.ReadCoil((ushort)(ReadOnlyStartAddress + MutedLoadOffsetAddress));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
using RobotNet.VDA5050.Type;
|
||||
using RobotNet10.RobotApp.Interfaces;
|
||||
|
||||
namespace RobotNet10.RobotApp.Services.Robot;
|
||||
|
||||
public partial class RobotPlcController
|
||||
{
|
||||
public OperatingMode PeripheralMode { get; private set; }
|
||||
public SafetySpeed SafetySpeed { get; private set; }
|
||||
|
||||
public bool Emergency { get; private set; }
|
||||
public bool Bumper { get; private set; }
|
||||
|
||||
public bool LidarFrontProtectField { get; private set; }
|
||||
public bool LidarBackProtectField { get; private set; }
|
||||
public bool LidarFrontTimProtectField { get; private set; }
|
||||
|
||||
// Lift state - now cached instead of direct read
|
||||
public bool LiftedUp { get; private set; }
|
||||
public bool LiftedDown { get; private set; }
|
||||
public bool LiftHome { get; private set; }
|
||||
|
||||
// Motor state - now cached instead of direct read
|
||||
public bool LeftMotorReady { get; private set; }
|
||||
public bool RightMotorReady { get; private set; }
|
||||
public bool LiftMotorReady { get; private set; }
|
||||
|
||||
public bool ButtonStart { get; private set; }
|
||||
public bool ButtonStop { get; private set; }
|
||||
public bool ButtonReset { get; private set; }
|
||||
|
||||
// Other state - now cached instead of direct read
|
||||
public bool HasLoad { get; private set; }
|
||||
public bool EnabledCharger { get; private set; }
|
||||
public bool Charging { get; private set; }
|
||||
public bool MutedBase { get; private set; }
|
||||
public bool MutedLoad { get; private set; }
|
||||
|
||||
// Write state tracking - đọc từ write addresses của PLC
|
||||
public SystemState CurrentSystemState => ReadSystemState();
|
||||
public OperationState CurrentOperationState => ReadOperationState();
|
||||
public RFMode CurrentRFMode => ReadRFMode();
|
||||
public bool SetHorizontalLoadValue => ModbusTcpDevice?.ReadCoil(SetHorizontalLoadAddress) ?? false;
|
||||
public bool SetMutedBaseValue => ModbusTcpDevice?.ReadCoil(SetMutedBaseAddress) ?? false;
|
||||
public bool SetMutedLoadValue => ModbusTcpDevice?.ReadCoil(SetMutedLoadAddress) ?? false;
|
||||
public bool SetEnableChargerValue => ModbusTcpDevice?.ReadCoil(EnableChargerAddress) ?? false;
|
||||
public bool SetHasLoadValue => ModbusTcpDevice?.ReadCoil(SetHasLoadAddress) ?? false;
|
||||
public bool SetRFEStopValue => ModbusTcpDevice?.ReadCoil(SetRFEStopAddress) ?? false;
|
||||
public bool SetBatteryLowValue => ModbusTcpDevice?.ReadCoil(SetBatteryLowAddress) ?? false;
|
||||
public bool SetLightOnValue => ModbusTcpDevice?.ReadCoil(SetLightOnAddress) ?? false;
|
||||
|
||||
private SystemState ReadSystemState()
|
||||
{
|
||||
var device = ModbusTcpDevice;
|
||||
if (device is null) return SystemState.INIT;
|
||||
|
||||
bool[] states = device.ReadCoils(RobotStateWriteAddress, 10);
|
||||
if (states.Length == 10)
|
||||
{
|
||||
// Decode state from one-hot encoded coils
|
||||
if (states[0]) return SystemState.INIT;
|
||||
else if (states[1]) return SystemState.PAUSED;
|
||||
else if (states[2]) return SystemState.IDLE;
|
||||
else if (states[3]) return SystemState.PROCCESSING;
|
||||
else if (states[4]) return SystemState.DOCKING;
|
||||
else if (states[5]) return SystemState.MAINTENANCE;
|
||||
else if (states[6]) return SystemState.MANUAL;
|
||||
else if (states[7]) return SystemState.OVERRIDE;
|
||||
else if (states[8]) return SystemState.CHARGING;
|
||||
else if (states[9]) return SystemState.ERROR;
|
||||
}
|
||||
else Logger.Warning($"Read system state is failed: data length {states.Length} is wrong.");
|
||||
return SystemState.INIT;
|
||||
}
|
||||
|
||||
private OperationState ReadOperationState()
|
||||
{
|
||||
var device = ModbusTcpDevice;
|
||||
if (device is null) return OperationState.None;
|
||||
|
||||
bool[] states = device.ReadCoils(RobotOperationWriteAddress, 3);
|
||||
if (states.Length == 3)
|
||||
{
|
||||
// Decode operation state from one-hot encoded coils
|
||||
if (states[0]) return OperationState.Move;
|
||||
else if (states[1]) return OperationState.Lifting;
|
||||
else if (states[2]) return OperationState.LiftRotating;
|
||||
else return OperationState.None;
|
||||
}
|
||||
else Logger.Warning($"Read operation state is failed: data length {states.Length} is wrong.");
|
||||
return OperationState.None;
|
||||
}
|
||||
|
||||
private RFMode ReadRFMode()
|
||||
{
|
||||
var device = ModbusTcpDevice;
|
||||
if (device is null) return RFMode.None;
|
||||
|
||||
bool[] modes = device.ReadCoils(SetRFModeAddress, 3);
|
||||
if (modes.Length == 3)
|
||||
{
|
||||
// Decode RF mode from one-hot encoded coils
|
||||
if (modes[0]) return RFMode.Default;
|
||||
else if (modes[1]) return RFMode.Maintenance;
|
||||
else if (modes[2]) return RFMode.Override;
|
||||
else return RFMode.None;
|
||||
}
|
||||
else Logger.Warning($"Read RF mode is failed: data length {modes.Length} is wrong.");
|
||||
return RFMode.None;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,277 @@
|
||||
using RobotNet.VDA5050;
|
||||
using RobotNet.VDA5050.State;
|
||||
using RobotNet.VDA5050.Type;
|
||||
using RobotNet10.Common;
|
||||
using RobotNet10.RobotApp.Interfaces;
|
||||
using RobotNet10.RobotApp.Services.Exceptions;
|
||||
using RobotNet10.RobotApp.Services.Robot.Actions;
|
||||
using RobotNet10.RobotApp.Services.Robot.Helper;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Diagnostics;
|
||||
|
||||
namespace RobotNet10.RobotApp.Services.Robot;
|
||||
|
||||
public class RobotActionController(ILogger<RobotActionController> Logger,
|
||||
IRobotActionProvider RobotActionProvider,
|
||||
IError ErrorManager,
|
||||
INavigation NavigationManager,
|
||||
IServiceScopeFactory ServiceScopeFactory) : BackgroundService, IAction
|
||||
{
|
||||
public ActionState[] ActionStates => [.. Actions.Values.OrderBy(a => a.SequenceNumber).Select(a => new ActionState
|
||||
{
|
||||
ActionId = a.Id,
|
||||
ActionType = a.Type.ToJsonString(),
|
||||
ActionDescription = a.Description,
|
||||
ActionStatus = a.Status,
|
||||
ResultDescription = a.ResultDescription,
|
||||
})];
|
||||
public bool HasActionRunning => !ActionQueue.IsEmpty || Actions.Values.Any(a => a.Type != ActionType.CANCEL_ORDER && !a.IsCompleted);
|
||||
public bool HasActionWaitting => !ActionQueue.IsEmpty;
|
||||
|
||||
private readonly ConcurrentDictionary<string, RobotAction> Actions = [];
|
||||
private readonly ConcurrentQueue<(ActionScope scope, RobotNet.VDA5050.InstantAction.Action action)> ActionQueue = [];
|
||||
private readonly ActionConflictDetector _conflictDetector = new();
|
||||
|
||||
private WatchThread<RobotActionController>? HandlerTimer;
|
||||
private const int HandlerInterval = 200;
|
||||
private const int CompletedActionRetentionMs = 300000; // 5 minutes
|
||||
private int _cleanupCounter = 0;
|
||||
private const int CleanupIntervalIterations = 50; // Cleanup every 50 iterations (10 seconds)
|
||||
private volatile bool _isClearing = false;
|
||||
private long _sequenceCounter = 0;
|
||||
|
||||
public RobotAction? this[string actionId] => Actions.TryGetValue(actionId, out RobotAction? action) && action is not null ? action : null;
|
||||
|
||||
public void AddInstantAction(RobotNet.VDA5050.InstantAction.Action[] actions)
|
||||
{
|
||||
using var scope = ServiceScopeFactory.CreateAsyncScope();
|
||||
var OrderManager = scope.ServiceProvider.GetRequiredService<IOrder>();
|
||||
foreach (var action in actions)
|
||||
{
|
||||
if (Actions.TryGetValue(action.ActionId, out _)) continue;
|
||||
|
||||
// VDA5050: Check for conflicts with ALL running actions (ORDER + INSTANT)
|
||||
var runningActions = Actions.Values.Where(a => !a.IsCompleted).ToList();
|
||||
bool isOrderActive = OrderManager.NodeStates.Length > 0 || OrderManager.EdgeStates.Length > 0;
|
||||
bool isDriving = NavigationManager.Driving;
|
||||
|
||||
var conflictResult = _conflictDetector.CheckConflict(action, runningActions, isOrderActive, isDriving);
|
||||
|
||||
if (conflictResult.HasConflict)
|
||||
{
|
||||
// Reject instant action and report error
|
||||
var error = new RobotError
|
||||
{
|
||||
ErrorType = "instantActionConflict",
|
||||
ErrorLevel = ErrorLevel.WARNING,
|
||||
ErrorDescription = $"INSTANT action {action.ActionType} bị từ chối: {conflictResult.Description}",
|
||||
ErrorReferences = [
|
||||
new() { ReferenceKey = "actionId", ReferenceValue = action.ActionId },
|
||||
new() { ReferenceKey = "conflictType", ReferenceValue = conflictResult.Type.ToString() }
|
||||
]
|
||||
};
|
||||
|
||||
if (!string.IsNullOrEmpty(conflictResult.ConflictingActionId))
|
||||
{
|
||||
error.ErrorReferences = [
|
||||
.. error.ErrorReferences,
|
||||
new() { ReferenceKey = "conflictingActionId", ReferenceValue = conflictResult.ConflictingActionId }
|
||||
];
|
||||
}
|
||||
|
||||
ErrorManager.AddError(error, TimeSpan.FromSeconds(10));
|
||||
Logger.LogWarning($"INSTANT action {action.ActionId} (type: {action.ActionType}) rejected due to conflict: {conflictResult.Description}");
|
||||
continue; // Skip this action
|
||||
}
|
||||
|
||||
// No conflict - add to queue
|
||||
ActionQueue.Enqueue((ActionScope.INSTANT, action));
|
||||
}
|
||||
}
|
||||
|
||||
public void AddOrderActions(RobotNet.VDA5050.InstantAction.Action[] actions, ActionScope scope = ActionScope.NODE)
|
||||
{
|
||||
foreach (var action in actions)
|
||||
{
|
||||
if (Actions.TryGetValue(action.ActionId, out _)) continue;
|
||||
ActionQueue.Enqueue((scope, action));
|
||||
}
|
||||
}
|
||||
|
||||
public IEnumerable<RobotAction> GetRunningActions()
|
||||
{
|
||||
return Actions.Values.Where(a => !a.IsCompleted);
|
||||
}
|
||||
|
||||
public void StartOrderAction(string actionId)
|
||||
{
|
||||
if (Actions.TryGetValue(actionId, out RobotAction? robotAction) && robotAction is not null)
|
||||
{
|
||||
robotAction.Start();
|
||||
}
|
||||
}
|
||||
|
||||
public void StopOrderAction(string actionId = "")
|
||||
{
|
||||
if (string.IsNullOrEmpty(actionId))
|
||||
{
|
||||
foreach (var action in Actions.Values)
|
||||
{
|
||||
if (!action.IsCompleted && action.Type != ActionType.CANCEL_ORDER) action.Cancel();
|
||||
}
|
||||
}
|
||||
else if (Actions.TryGetValue(actionId, out RobotAction? robotAction) && robotAction is not null) robotAction.Cancel();
|
||||
}
|
||||
|
||||
public void FinishAction(string actionId)
|
||||
{
|
||||
if (Actions.TryGetValue(actionId, out RobotAction? robotAction) && robotAction is not null)
|
||||
{
|
||||
robotAction.Finish();
|
||||
}
|
||||
}
|
||||
|
||||
public void PauseActions()
|
||||
{
|
||||
foreach (var action in Actions.Values)
|
||||
{
|
||||
action.Pause();
|
||||
}
|
||||
}
|
||||
|
||||
public void ResumeActions()
|
||||
{
|
||||
foreach (var action in Actions.Values)
|
||||
{
|
||||
action.Resume();
|
||||
}
|
||||
}
|
||||
|
||||
private void ActionHandler()
|
||||
{
|
||||
if (_isClearing) return;
|
||||
|
||||
while (!ActionQueue.IsEmpty)
|
||||
{
|
||||
if (!ActionQueue.TryDequeue(out var result)) continue;
|
||||
if (Actions.ContainsKey(result.action.ActionId)) continue;
|
||||
|
||||
RobotAction? robotAction = null;
|
||||
try
|
||||
{
|
||||
if (EnumHelper.TryParse(result.action.ActionType, out ActionType actionType))
|
||||
{
|
||||
robotAction = RobotActionProvider.GetRobotAction(actionType);
|
||||
if (robotAction is not null)
|
||||
{
|
||||
robotAction.Initialize(result.scope, result.action);
|
||||
robotAction.SequenceNumber = Interlocked.Increment(ref _sequenceCounter);
|
||||
Actions.TryAdd(result.action.ActionId, robotAction);
|
||||
if (result.scope == ActionScope.INSTANT) robotAction.Start();
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
var error = new RobotError
|
||||
{
|
||||
ErrorType = "actionTypeInvalid",
|
||||
ErrorLevel = RobotNet.VDA5050.Type.ErrorLevel.WARNING,
|
||||
ErrorDescription = $"ActionType không hợp lệ: {result.action.ActionType}",
|
||||
ErrorReferences = [new() { ReferenceKey = "actionId", ReferenceValue = result.action.ActionId }]
|
||||
};
|
||||
ErrorManager.AddError(error, TimeSpan.FromSeconds(10));
|
||||
Logger.LogWarning("ActionType không hợp lệ: {ActionType} cho action {ActionId}", result.action.ActionType, result.action.ActionId);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
var errorMsg = ex is RobotException rex && rex.Error is not null ? rex.Error.ErrorDescription : ex.Message;
|
||||
|
||||
// Nếu robotAction đã tạo, mark FAILED và add vào Actions để FM nhận được trạng thái
|
||||
if (robotAction is not null)
|
||||
{
|
||||
robotAction.Cancel();
|
||||
robotAction.ResultDescription = $"Khởi tạo action thất bại: {errorMsg}";
|
||||
robotAction.SequenceNumber = Interlocked.Increment(ref _sequenceCounter);
|
||||
Actions.TryAdd(result.action.ActionId, robotAction);
|
||||
}
|
||||
|
||||
var error = new RobotError
|
||||
{
|
||||
ErrorType = "actionInitializationFailed",
|
||||
ErrorLevel = RobotNet.VDA5050.Type.ErrorLevel.WARNING,
|
||||
ErrorDescription = $"Action {result.action.ActionId} ({result.action.ActionType}) khởi tạo thất bại: {errorMsg}",
|
||||
ErrorReferences = [new() { ReferenceKey = "actionId", ReferenceValue = result.action.ActionId }]
|
||||
};
|
||||
ErrorManager.AddError(error, TimeSpan.FromSeconds(10));
|
||||
Logger.LogWarning("Action {ActionId} (type: {ActionType}) initialization failed: {Error}", result.action.ActionId, result.action.ActionType, errorMsg);
|
||||
}
|
||||
}
|
||||
|
||||
// Cleanup completed actions periodically to prevent memory leak
|
||||
_cleanupCounter++;
|
||||
if (_cleanupCounter >= CleanupIntervalIterations)
|
||||
{
|
||||
_cleanupCounter = 0;
|
||||
CleanupCompletedActions();
|
||||
}
|
||||
}
|
||||
|
||||
private void CleanupCompletedActions()
|
||||
{
|
||||
try
|
||||
{
|
||||
long currentTime = Stopwatch.GetTimestamp() * 1000 / Stopwatch.Frequency;
|
||||
var actionsToRemove = Actions.Where(kvp =>
|
||||
kvp.Value.IsCompleted &&
|
||||
kvp.Value.CompletionTime > 0 &&
|
||||
(currentTime - kvp.Value.CompletionTime) > CompletedActionRetentionMs
|
||||
).Select(kvp => kvp.Key).ToList();
|
||||
|
||||
foreach (var actionId in actionsToRemove)
|
||||
{
|
||||
if (Actions.TryGetValue(actionId, out var action))
|
||||
{
|
||||
_ = action.DisposeAsync(); // Fire and forget disposal
|
||||
Actions.TryRemove(actionId, out _);
|
||||
Logger.LogDebug($"Cleaned up completed action: {actionId} (Type: {action.Type})");
|
||||
}
|
||||
}
|
||||
|
||||
if (actionsToRemove.Count > 0)
|
||||
{
|
||||
Logger.LogInformation($"Cleaned up {actionsToRemove.Count} completed actions");
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogWarning($"Error during action cleanup: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
public async Task ClearActions()
|
||||
{
|
||||
_isClearing = true;
|
||||
ActionQueue.Clear();
|
||||
var disposeTasks = Actions.Values.Select(action => action.DisposeAsync().AsTask()).ToList();
|
||||
await Task.WhenAll(disposeTasks).ConfigureAwait(false);
|
||||
Actions.Clear();
|
||||
ActionQueue.Clear(); // Clear lần nữa phòng trường hợp có action mới enqueue trong lúc dispose
|
||||
Interlocked.Exchange(ref _sequenceCounter, 0);
|
||||
_isClearing = false;
|
||||
}
|
||||
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
await Task.Yield();
|
||||
HandlerTimer = new(HandlerInterval, ActionHandler, Logger);
|
||||
HandlerTimer.Start();
|
||||
}
|
||||
|
||||
public override Task StopAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
HandlerTimer?.Dispose();
|
||||
HandlerTimer = null;
|
||||
return base.StopAsync(cancellationToken);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,732 @@
|
||||
using RobotNet.VDA5050.Type;
|
||||
using RobotNet10.Common;
|
||||
using RobotNet10.RobotApp.Devices;
|
||||
using RobotNet10.RobotApp.Events;
|
||||
using RobotNet10.RobotApp.Events.Events;
|
||||
using RobotNet10.RobotApp.Interfaces;
|
||||
using RobotNet10.RobotApp.Modules;
|
||||
using RobotNet10.RobotApp.Motion;
|
||||
using RobotNet10.RobotApp.Services.ConfigManager;
|
||||
using RobotNet10.RobotApp.Services.Exceptions;
|
||||
using RobotNet10.RobotApp.Services.Robot.Connection;
|
||||
using RobotNet10.RobotApp.Services.State;
|
||||
|
||||
namespace RobotNet10.RobotApp.Services.Robot;
|
||||
|
||||
public partial class RobotController(IOrder OrderManager,
|
||||
INavigation NavigationManager,
|
||||
IAction ActionManager,
|
||||
IPlcController PlcController,
|
||||
IDeviceProvider DeviceProvider,
|
||||
IConfiguration Configuration,
|
||||
IError ErrorManager,
|
||||
Logger<RobotController> Logger,
|
||||
IRobotConnectionsService RobotConnectionsService,
|
||||
IRobotEventBus RobotEventBus,
|
||||
RobotStateMachine StateManager,
|
||||
ManualControlService RFControl,
|
||||
PS5ControllerService Ps5Controller,
|
||||
IRobotConfiguration RobotConfiguration,
|
||||
RobotStates StateService,
|
||||
RobotVisualization VisualizationService,
|
||||
ILiftModule LiftModule,
|
||||
ILocalization Localization,
|
||||
IRotationModule RotateModule,
|
||||
IInverseKinematics? InverseKinematics = null) : BackgroundService, IRobotController
|
||||
{
|
||||
private readonly Mutex NewOrderMutex = new();
|
||||
private readonly Mutex NewInstanceMutex = new();
|
||||
private readonly Lock _stateTransitionLock = new();
|
||||
private WatchThread<RobotController>? _watchTimer;
|
||||
private bool _rfHandleHasPriority = false;
|
||||
private IBattery? Battery;
|
||||
private OperatingMode _previousPlcMode = OperatingMode.SERVICE;
|
||||
private double _batteryLowThresholdPercent = 20.0;
|
||||
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
await Task.Yield();
|
||||
await StateManager.InitializeAsync();
|
||||
while (!stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
if (StateManager.CurrentState == RobotStateType.Standby) break;
|
||||
await Task.Delay(1000, stoppingToken);
|
||||
}
|
||||
|
||||
// Subscribe to PLC events
|
||||
PlcController.OnPeripheralModeChanged += OnPlcModeChanged;
|
||||
PlcController.OnStop += OnStop;
|
||||
PlcController.OnButtonPressed += OnButtonPressed;
|
||||
|
||||
// Subscribe to RF Handle mode changes (via RobotController for PLC sync)
|
||||
RFControl.OnRfModeChanged += OnRfModeChanged;
|
||||
|
||||
// Subscribe to fatal errors
|
||||
ErrorManager.OnNewFatalError += OnNewFatalError;
|
||||
|
||||
// Start WatchThread at 5Hz (200ms)
|
||||
_watchTimer = new WatchThread<RobotController>(200, WatchThreadCallback, null);
|
||||
_watchTimer.Start();
|
||||
|
||||
var deviceBattery = DeviceProvider.GetDeviceByType(Client.Shared.Devices.DeviceType.Battery);
|
||||
if(deviceBattery is IBattery battery) Battery = battery;
|
||||
_batteryLowThresholdPercent = ResolveBatteryLowThresholdPercent();
|
||||
|
||||
// Initial mode switch based on current PLC mode
|
||||
_previousPlcMode = PlcController.PeripheralMode;
|
||||
SwitchModeChanged(PlcController.PeripheralMode);
|
||||
PlcController.SetRFMode(RFMode.None);
|
||||
while (!stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
if(RFControl.IsRunning)
|
||||
{
|
||||
RFControl.Start();
|
||||
break;
|
||||
}
|
||||
await Task.Delay(2000);
|
||||
}
|
||||
}
|
||||
|
||||
public override Task StopAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
StopHandler();
|
||||
return base.StopAsync(cancellationToken);
|
||||
}
|
||||
|
||||
public async Task ModuleInitializeAsync()
|
||||
{
|
||||
while (true)
|
||||
{
|
||||
if (StateManager.IsInitialized) break;
|
||||
await Task.Delay(500);
|
||||
}
|
||||
|
||||
// Start MQTT independently so connection topics are available even if hardware init is slow.
|
||||
_ = RobotConnectionsService.StartAsync(CancellationToken.None);
|
||||
// Start VDA5050 publishers early so state/visualization topics keep updating.
|
||||
StateService.Start();
|
||||
VisualizationService.Start();
|
||||
|
||||
while (true)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!RobotConfiguration.GetSimulationConfig().IsEnable)
|
||||
{
|
||||
Logger.Info("Checking hardware...");
|
||||
|
||||
await PlcController.Start(CancellationToken.None);
|
||||
|
||||
while (!PlcController.IsReady || !DeviceProvider.AreDevicesConnected)
|
||||
{
|
||||
// (!DeviceProvider.AreDevicesConnected) Logger.Info(" - Devices service not ready");
|
||||
//if (!PlcController.IsReady) Logger.Info(" - Peripheral service not ready");
|
||||
if (PlcController.IsReady) PlcController.SetSystemState(SystemState.INIT);
|
||||
// if (!LiftModule.IsReady) Logger.Info(" - LiftModule service not ready");
|
||||
// if (!RotateModule.IsReady) Logger.Info(" - RotateModule service not ready");
|
||||
await Task.Delay(3000);
|
||||
}
|
||||
}
|
||||
Logger.Info("Hardware modules ready");
|
||||
|
||||
// Start software modules independently
|
||||
NavigationManager.Start();
|
||||
|
||||
StateManager.Fire(RobotEventType.InitializeCompleted);
|
||||
Logger.Info("Initialization completed");
|
||||
break;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.Warning($"Robot initialize failed: {ex.Message}");
|
||||
await Task.Delay(2000);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public void StopHandler()
|
||||
{
|
||||
_watchTimer?.Dispose();
|
||||
_watchTimer = null;
|
||||
|
||||
if (RobotConnectionsService.IsConnected)
|
||||
{
|
||||
var pubOffline = RobotConnectionsService.PublishConnectionStateAsync(ConnectionState.OFFLINE);
|
||||
pubOffline.Wait();
|
||||
}
|
||||
|
||||
var stopConnection = RobotConnectionsService.StopAsync();
|
||||
stopConnection.Wait();
|
||||
RobotEventBus.OrderMessageReceived -= NewOrderUpdated;
|
||||
RobotEventBus.InstantActionReceived -= NewInstantActionUpdated;
|
||||
NavigationManager.Stop();
|
||||
PlcController.Stop();
|
||||
PlcController.OnPeripheralModeChanged -= OnPlcModeChanged;
|
||||
PlcController.OnStop -= OnStop;
|
||||
PlcController.OnButtonPressed -= OnButtonPressed;
|
||||
RFControl.OnRfModeChanged -= OnRfModeChanged;
|
||||
ErrorManager.OnNewFatalError -= OnNewFatalError;
|
||||
}
|
||||
|
||||
public void NewOrderUpdated(object? sender, OrderChangedEvent e)
|
||||
{
|
||||
if (NewOrderMutex.WaitOne(2000))
|
||||
{
|
||||
try
|
||||
{
|
||||
var orderMsg = e.OrderMessage;
|
||||
if (!StateManager.IsInState(RobotStateType.Auto)) throw new OrderException(RobotErrors.Error1006(StateManager.CurrentState.ToString()));
|
||||
if (!Localization.IsReady) throw new OrderException(RobotErrors.Error3001());
|
||||
OrderManager.UpdateOrder(orderMsg);
|
||||
}
|
||||
catch (RobotException orEx)
|
||||
{
|
||||
if (orEx.Error is not null)
|
||||
{
|
||||
ErrorManager.AddError(orEx.Error, TimeSpan.FromSeconds(10));
|
||||
Logger.Warning($"New order error: {orEx.Error.ErrorDescription}");
|
||||
}
|
||||
else Logger.Warning($"New order error: {orEx.Message}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.Warning($"Order processing error: {ex.Message}");
|
||||
}
|
||||
finally
|
||||
{
|
||||
NewOrderMutex.ReleaseMutex();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public void NewInstantActionUpdated(object? sender, InstantActionChangedEvent e)
|
||||
{
|
||||
if (NewInstanceMutex.WaitOne(2000))
|
||||
{
|
||||
try
|
||||
{
|
||||
var instantAction = e.InstantActionMessage;
|
||||
|
||||
// VDA5050: Filter instant actions based on current robot state
|
||||
var filteredActions = FilterInstantActionsByState(instantAction.Actions);
|
||||
|
||||
if (filteredActions.Length > 0)
|
||||
{
|
||||
ActionManager.AddInstantAction(filteredActions);
|
||||
}
|
||||
}
|
||||
catch (RobotException acEx)
|
||||
{
|
||||
if (acEx.Error is not null)
|
||||
{
|
||||
ErrorManager.AddError(acEx.Error, TimeSpan.FromSeconds(10));
|
||||
Logger.Warning($"InstantAction error: {acEx.Error.ErrorDescription}");
|
||||
}
|
||||
else Logger.Warning($"InstantAction error: {acEx.Message}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.Warning($"InstantAction processing error: {ex.Message}");
|
||||
}
|
||||
finally
|
||||
{
|
||||
NewInstanceMutex.ReleaseMutex();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Filter instant actions based on current robot state for security and safety
|
||||
/// </summary>
|
||||
private RobotNet.VDA5050.InstantAction.Action[] FilterInstantActionsByState(RobotNet.VDA5050.InstantAction.Action[] actions)
|
||||
{
|
||||
var currentState = StateManager.CurrentState;
|
||||
var allowedActions = new List<RobotNet.VDA5050.InstantAction.Action>();
|
||||
|
||||
foreach (var action in actions)
|
||||
{
|
||||
bool isAllowed = IsActionAllowedInState(action.ActionType, currentState);
|
||||
|
||||
if (isAllowed)
|
||||
{
|
||||
allowedActions.Add(action);
|
||||
}
|
||||
else
|
||||
{
|
||||
// VDA5050: Report rejected instant action as error
|
||||
var error = new RobotError
|
||||
{
|
||||
ErrorType = "instantActionRejected",
|
||||
ErrorLevel = RobotNet.VDA5050.Type.ErrorLevel.WARNING,
|
||||
ErrorDescription = $"Instant action '{action.ActionType}' rejected - not allowed in state '{currentState}'",
|
||||
ErrorReferences = [
|
||||
new() { ReferenceKey = "actionId", ReferenceValue = action.ActionId },
|
||||
new() { ReferenceKey = "actionType", ReferenceValue = action.ActionType },
|
||||
new() { ReferenceKey = "robotState", ReferenceValue = currentState.ToString() }
|
||||
]
|
||||
};
|
||||
ErrorManager.AddError(error, TimeSpan.FromSeconds(10));
|
||||
Logger.Warning($"Instant action {action.ActionId} (type: {action.ActionType}) rejected - not allowed in state {currentState}");
|
||||
}
|
||||
}
|
||||
|
||||
return [.. allowedActions];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Define which instant actions are allowed in each robot state
|
||||
/// </summary>
|
||||
private static bool IsActionAllowedInState(string actionType, RobotStateType state)
|
||||
{
|
||||
// Actions allowed in ALL states (read-only or critical control)
|
||||
var alwaysAllowedActions = new HashSet<string>
|
||||
{
|
||||
"cancelOrder", // VDA5050: Must work in all states
|
||||
"stateRequest", // Read-only
|
||||
"factsheetRequest", // Read-only
|
||||
};
|
||||
|
||||
if (alwaysAllowedActions.Contains(actionType)) return true;
|
||||
|
||||
// Actions allowed only in Auto state
|
||||
if (state == RobotStateType.Auto ||
|
||||
state == RobotStateType.Idle ||
|
||||
state == RobotStateType.Executing ||
|
||||
state == RobotStateType.Paused ||
|
||||
state == RobotStateType.Canceling)
|
||||
{
|
||||
return true; // All actions allowed in Auto mode
|
||||
}
|
||||
|
||||
// Shared set of maintenance/setup actions (used in Service, Manual, System, Standby)
|
||||
var maintenanceAllowedActions = new HashSet<string>
|
||||
{
|
||||
"initPosition",
|
||||
"pick",
|
||||
"drop",
|
||||
"rotate",
|
||||
"liftRotate",
|
||||
"homingCamera",
|
||||
"liftCameraByHeight",
|
||||
"controlLight",
|
||||
"cameraLightOn",
|
||||
"cameraLightOff",
|
||||
"mutedBaseOn",
|
||||
"mutedBaseOff",
|
||||
"mutedLoadOn",
|
||||
"mutedLoadOff",
|
||||
"dockTo",
|
||||
"moveStraightToCoor",
|
||||
"moveStraightWithDistance"
|
||||
};
|
||||
|
||||
// Actions allowed in Service/Override/Manual states (maintenance/manual control)
|
||||
if (state == RobotStateType.Service ||
|
||||
state == RobotStateType.Remote_Override ||
|
||||
state == RobotStateType.Manual)
|
||||
{
|
||||
return maintenanceAllowedActions.Contains(actionType);
|
||||
}
|
||||
|
||||
// After ReleaseStop robot goes to System/Standby - allow maintenance actions so operator can e.g. lift camera before switching mode
|
||||
if (state == RobotStateType.System || state == RobotStateType.Standby)
|
||||
{
|
||||
return maintenanceAllowedActions.Contains(actionType);
|
||||
}
|
||||
|
||||
// Stop and Fault states: only critical control actions
|
||||
if (state == RobotStateType.Stop || state == RobotStateType.Fault)
|
||||
{
|
||||
// Already handled by alwaysAllowedActions above
|
||||
return false;
|
||||
}
|
||||
|
||||
// Default: reject
|
||||
return false;
|
||||
}
|
||||
|
||||
public void Pause()
|
||||
{
|
||||
OrderManager.PauseOrder();
|
||||
ActionManager.PauseActions();
|
||||
}
|
||||
|
||||
public void Resume()
|
||||
{
|
||||
OrderManager.ResumeOrder();
|
||||
ActionManager.ResumeActions();
|
||||
}
|
||||
|
||||
public bool TryClearFault()
|
||||
{
|
||||
lock (_stateTransitionLock)
|
||||
{
|
||||
if (!StateManager.IsInState(RobotStateType.Fault)) return false;
|
||||
|
||||
if (PlcController.IsReady && !PlcController.IsDisconected)
|
||||
ErrorManager.DeleteErrorId(2003);
|
||||
|
||||
ErrorManager.ClearFatalErrors();
|
||||
|
||||
if (!ErrorManager.HasFatalError)
|
||||
{
|
||||
Logger.Info("TryClearFault: Exiting Fault");
|
||||
StateManager.Fire(RobotEventType.ExitFault);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private void OnPlcModeChanged(OperatingMode mode)
|
||||
{
|
||||
lock (_stateTransitionLock)
|
||||
{
|
||||
if (_rfHandleHasPriority)
|
||||
{
|
||||
Logger.Info($"PLC mode change to {mode} ignored - RF Handle has priority");
|
||||
return;
|
||||
}
|
||||
// Khi chuyển từ Lock (SERVICE) sang Auto hoặc Manual: ghi M815 xuống PLC, reset fault, enable động cơ
|
||||
if (_previousPlcMode == OperatingMode.SERVICE && (mode == OperatingMode.AUTOMATIC || mode == OperatingMode.MANUAL))
|
||||
{
|
||||
Logger.Info($"PLC Lock -> {mode}: áp dụng ApplyResetFromPlc (M815 + fault reset + enable)");
|
||||
ApplyResetFromPlc();
|
||||
}
|
||||
_previousPlcMode = mode;
|
||||
SwitchModeChanged(mode);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Reset theo PLC (M815): ghi M815 xuống PLC (pulse), clear fault robot, reset fault động cơ, enable lại động cơ (retry đến khi OperationEnabled).</summary>
|
||||
private void ApplyResetFromPlc()
|
||||
{
|
||||
Logger.Info("ApplyResetFromPlc: bắt đầu (M815 pulse, clear fault, fault reset + enable drive)");
|
||||
try { PlcController.WriteAlarmResetM815(); } catch (Exception ex) { Logger.Warning($"WriteAlarmResetM815: {ex.Message}"); }
|
||||
TryClearFault();
|
||||
try
|
||||
{
|
||||
InverseKinematics?.FaultReset();
|
||||
// Đợi servo thoát Fault (CiA402 có thể cần >1s để cập nhật statusword)
|
||||
Thread.Sleep(1500);
|
||||
InverseKinematics?.FaultReset();
|
||||
Thread.Sleep(800);
|
||||
// Enable 2 động cơ giống enable bằng tay trên device: gửi lệnh trực tiếp, await từng bước
|
||||
if (InverseKinematics != null)
|
||||
{
|
||||
InverseKinematics.EnableAsync(CancellationToken.None).GetAwaiter().GetResult();
|
||||
if (InverseKinematics.IsOperationEnabled)
|
||||
Logger.Info("ApplyResetFromPlc: 2 động cơ đã enable (OperationEnabled)");
|
||||
else
|
||||
Logger.Warning("ApplyResetFromPlc: động cơ chưa lên OperationEnabled sau EnableAsync");
|
||||
}
|
||||
}
|
||||
catch (Exception ex) { Logger.Warning($"FaultReset/Enable drive: {ex.Message}"); }
|
||||
}
|
||||
|
||||
private void SwitchModeChanged(OperatingMode mode)
|
||||
{
|
||||
// Pause order when leaving Auto mode
|
||||
if (StateManager.IsInState(RobotStateType.Auto) && mode != OperatingMode.AUTOMATIC)
|
||||
{
|
||||
Pause();
|
||||
RobotEventBus.OrderMessageReceived -= NewOrderUpdated;
|
||||
// Keep InstantActionReceived subscription - instant actions (e.g. cancelOrder) must work in all states
|
||||
}
|
||||
|
||||
switch (mode)
|
||||
{
|
||||
case OperatingMode.AUTOMATIC:
|
||||
Ps5Controller.Disable();
|
||||
StateManager.Fire(RobotEventType.EnterAuto);
|
||||
// Prevent duplicate subscriptions
|
||||
RobotEventBus.OrderMessageReceived -= NewOrderUpdated;
|
||||
RobotEventBus.OrderMessageReceived += NewOrderUpdated;
|
||||
RobotEventBus.InstantActionReceived -= NewInstantActionUpdated;
|
||||
RobotEventBus.InstantActionReceived += NewInstantActionUpdated;
|
||||
Resume();
|
||||
break;
|
||||
case OperatingMode.MANUAL:
|
||||
Ps5Controller.Enable();
|
||||
StateManager.Fire(RobotEventType.EnterManual);
|
||||
break;
|
||||
case OperatingMode.SERVICE:
|
||||
Ps5Controller.Disable();
|
||||
StateManager.Fire(RobotEventType.EnterService);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private void OnStop(StopStateType state)
|
||||
{
|
||||
lock (_stateTransitionLock)
|
||||
{
|
||||
if (state != StopStateType.None)
|
||||
{
|
||||
_rfHandleHasPriority = false; // Safety overrides RF Handle
|
||||
if (!StateManager.IsInState(RobotStateType.Stop))
|
||||
{
|
||||
Pause();
|
||||
StateManager.Fire(RobotEventType.EnterStop);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
// No physical Start button: leave Stop as soon as PLC reports all safety inputs clear.
|
||||
TryReleaseStopAfterSafetyClear();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Exit Stop when EMC/bumper are released. Previously required a Start button; this robot has none.
|
||||
/// </summary>
|
||||
private void TryReleaseStopAfterSafetyClear()
|
||||
{
|
||||
if (!StateManager.IsInState(RobotStateType.Stop))
|
||||
return;
|
||||
if (PlcController.Emergency || PlcController.Bumper)
|
||||
return;
|
||||
|
||||
Logger.Info("Robot Controller: Safety cleared; releasing Stop (auto, no Start button)");
|
||||
StateManager.Fire(RobotEventType.ReleaseStop);
|
||||
}
|
||||
|
||||
private void OnButtonPressed(PeripheralButton button)
|
||||
{
|
||||
lock (_stateTransitionLock)
|
||||
{
|
||||
if (button == PeripheralButton.Reset)
|
||||
{
|
||||
// M815 Reset: clear robot fault + reset fault động cơ
|
||||
ApplyResetFromPlc();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void OnNewFatalError()
|
||||
{
|
||||
lock (_stateTransitionLock)
|
||||
{
|
||||
if (!StateManager.IsInState(RobotStateType.Fault))
|
||||
{
|
||||
_rfHandleHasPriority = false; // Fault overrides RF Handle
|
||||
Pause();
|
||||
StateManager.Fire(RobotEventType.EnterFault);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void OnRfModeChanged(RFMode rfMode)
|
||||
{
|
||||
lock (_stateTransitionLock)
|
||||
{
|
||||
// Ignore RF mode changes while in Stop or Fault — safety overrides everything
|
||||
// RF mode will be re-evaluated when returning to Standby via WatchThread
|
||||
if (StateManager.IsInState(RobotStateType.Stop) || StateManager.IsInState(RobotStateType.Fault))
|
||||
{
|
||||
Logger.Info($"RF mode change to {rfMode} ignored - robot in {StateManager.CurrentState}");
|
||||
return;
|
||||
}
|
||||
|
||||
switch (rfMode)
|
||||
{
|
||||
case RFMode.Maintenance:
|
||||
// RF Handle requests Service mode
|
||||
_rfHandleHasPriority = true;
|
||||
if (StateManager.IsInState(RobotStateType.Auto))
|
||||
{
|
||||
Pause();
|
||||
RobotEventBus.OrderMessageReceived -= NewOrderUpdated;
|
||||
// Keep InstantActionReceived subscription - instant actions must work in Service mode
|
||||
}
|
||||
StateManager.Fire(RobotEventType.EnterService);
|
||||
break;
|
||||
|
||||
case RFMode.Override:
|
||||
// RF Handle requests Remote Override
|
||||
_rfHandleHasPriority = true;
|
||||
if (StateManager.IsInState(RobotStateType.Auto))
|
||||
{
|
||||
Pause();
|
||||
RobotEventBus.OrderMessageReceived -= NewOrderUpdated;
|
||||
// Keep InstantActionReceived subscription - instant actions must work in Override mode
|
||||
}
|
||||
StateManager.Fire(RobotEventType.RemoteOverride);
|
||||
break;
|
||||
|
||||
case RFMode.Default:
|
||||
case RFMode.None:
|
||||
// RF Handle released control or disconnected - return to PLC-determined mode
|
||||
_rfHandleHasPriority = false;
|
||||
PlcController.SetRFEStop(false);
|
||||
if (StateManager.IsInState(RobotStateType.Service) || StateManager.IsInState(RobotStateType.Remote_Override))
|
||||
{
|
||||
SwitchModeChanged(PlcController.PeripheralMode);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void WatchThreadCallback()
|
||||
{
|
||||
lock (_stateTransitionLock)
|
||||
{
|
||||
// 1. Fatal error detection
|
||||
if (ErrorManager.HasFatalError && !StateManager.IsInState(RobotStateType.Fault))
|
||||
{
|
||||
Logger.Warning("Robot Controller: Fatal error detected, transitioning to Fault state");
|
||||
Pause();
|
||||
StateManager.Fire(RobotEventType.EnterFault);
|
||||
return;
|
||||
}
|
||||
|
||||
// 2. In Stop: release automatically when PLC shows safety clear (backup if OnStop edge was missed)
|
||||
if (StateManager.IsInState(RobotStateType.Stop))
|
||||
{
|
||||
TryReleaseStopAfterSafetyClear();
|
||||
return;
|
||||
}
|
||||
|
||||
// 2b. Fault auto-recovery
|
||||
if (StateManager.IsInState(RobotStateType.Fault))
|
||||
{
|
||||
if (PlcController.IsReady && !PlcController.IsDisconected)
|
||||
ErrorManager.DeleteErrorId(2003);
|
||||
|
||||
if (!ErrorManager.HasFatalError)
|
||||
{
|
||||
Logger.Info("Robot Controller: Fatal errors resolved, auto-recovering from Fault");
|
||||
StateManager.Fire(RobotEventType.ExitFault);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
var plcMode = PlcController.PeripheralMode;
|
||||
|
||||
// 3. If in Standby, trigger mode switch (e.g., after ReleaseStop or initialization)
|
||||
if (StateManager.CurrentState == RobotStateType.Standby)
|
||||
{
|
||||
// Check if RF Handle has an active mode that should take priority
|
||||
// (RF mode preserved on PLC during Stop/Fault, re-evaluated here after release)
|
||||
var rfMode = PlcController.CurrentRFMode;
|
||||
if (rfMode == RFMode.Maintenance)
|
||||
{
|
||||
Logger.Info("Robot Controller: Standby → RF Handle Maintenance detected, entering Service");
|
||||
_rfHandleHasPriority = true;
|
||||
StateManager.Fire(RobotEventType.EnterService);
|
||||
}
|
||||
else if (rfMode == RFMode.Override)
|
||||
{
|
||||
Logger.Info("Robot Controller: Standby → RF Handle Override detected, entering Remote_Override");
|
||||
_rfHandleHasPriority = true;
|
||||
StateManager.Fire(RobotEventType.RemoteOverride);
|
||||
}
|
||||
else
|
||||
{
|
||||
SwitchModeChanged(plcMode);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// 4. PLC mode mismatch check — ONLY when RF Handle does NOT have priority
|
||||
if (!_rfHandleHasPriority)
|
||||
{
|
||||
var currentModeMatch = plcMode switch
|
||||
{
|
||||
OperatingMode.AUTOMATIC => StateManager.IsInState(RobotStateType.Auto),
|
||||
OperatingMode.MANUAL => StateManager.IsInState(RobotStateType.Manual),
|
||||
OperatingMode.SERVICE => StateManager.IsInState(RobotStateType.Service),
|
||||
_ => true
|
||||
};
|
||||
|
||||
if (!currentModeMatch)
|
||||
{
|
||||
Logger.Warning($"Robot Controller: PLC mode mismatch. PLC: {plcMode}, State: {StateManager.CurrentState}");
|
||||
SwitchModeChanged(plcMode);
|
||||
}
|
||||
}
|
||||
|
||||
// 5. Backup stop detection (ALWAYS runs, even when RF Handle has priority)
|
||||
bool hasSafetyStop = PlcController.Emergency || PlcController.Bumper;
|
||||
|
||||
if (hasSafetyStop && !StateManager.IsInState(RobotStateType.Stop))
|
||||
{
|
||||
Logger.Warning("Robot Controller: Safety stop detected from PLC properties");
|
||||
_rfHandleHasPriority = false; // Safety overrides RF Handle
|
||||
Pause();
|
||||
StateManager.Fire(RobotEventType.EnterStop);
|
||||
}
|
||||
|
||||
// 6. Check has load
|
||||
if (LiftModule.IsReady && LiftModule.Position == LiftPosition.Top) PlcController.SetHasLoad(true);
|
||||
else PlcController.SetHasLoad(false);
|
||||
|
||||
// 7. Check Pin: set M929 when battery percentage is below configured threshold.
|
||||
if(Battery != null
|
||||
&& Battery.CurrentBatteryState.HasValue
|
||||
&& !double.IsNaN(Battery.CurrentBatteryState.Value.Percentage)
|
||||
&& Battery.CurrentBatteryState.Value.Percentage < _batteryLowThresholdPercent)
|
||||
{
|
||||
PlcController.SetBatteryLow(true);
|
||||
}
|
||||
else PlcController.SetBatteryLow(false);
|
||||
|
||||
// 8. Check PLC connection
|
||||
if (PlcController.IsDisconected)
|
||||
{
|
||||
ErrorManager.AddError(RobotErrors.Error2003());
|
||||
}
|
||||
else
|
||||
{
|
||||
ErrorManager.DeleteErrorId(2003);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private double ResolveBatteryLowThresholdPercent()
|
||||
{
|
||||
const double defaultThreshold = 20.0;
|
||||
try
|
||||
{
|
||||
if (Battery is not DeviceBase batteryDevice)
|
||||
{
|
||||
return defaultThreshold;
|
||||
}
|
||||
|
||||
var devicesSection = Configuration.GetSection("Devices");
|
||||
foreach (var section in devicesSection.GetChildren())
|
||||
{
|
||||
var deviceId = section.GetValue<string>("DeviceId");
|
||||
if (!string.Equals(deviceId, batteryDevice.DeviceId, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var threshold = section.GetSection("Connection").GetValue<double?>("LowBatteryThresholdPercent");
|
||||
if (!threshold.HasValue)
|
||||
{
|
||||
return defaultThreshold;
|
||||
}
|
||||
|
||||
var clamped = Math.Clamp(threshold.Value, 0.0, 100.0);
|
||||
if (Math.Abs(clamped - threshold.Value) > double.Epsilon)
|
||||
{
|
||||
Logger.Warning($"Battery low threshold {threshold.Value} out of range [0..100], clamped to {clamped}");
|
||||
}
|
||||
Logger.Info($"Battery low threshold loaded from config: {clamped}%");
|
||||
return clamped;
|
||||
}
|
||||
|
||||
return defaultThreshold;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.Warning($"Failed to resolve battery low threshold from config, fallback {defaultThreshold}%: {ex.Message}");
|
||||
return defaultThreshold;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
using RobotNet.VDA5050.State;
|
||||
using RobotNet.VDA5050.Type;
|
||||
using RobotNet10.RobotApp.Interfaces;
|
||||
|
||||
namespace RobotNet10.RobotApp.Services.Robot;
|
||||
|
||||
public class RobotError : Error
|
||||
{
|
||||
public int Id { get; set; }
|
||||
}
|
||||
|
||||
public class RobotErrors() : IError
|
||||
{
|
||||
public Error[] ErrorsState { get { lock (Errors) { return [.. Errors]; } } }
|
||||
public bool HasFatalError { get { lock (Errors) { return Errors.Any(e => e.ErrorLevel == ErrorLevel.FATAL); } } }
|
||||
public event System.Action? OnNewFatalError;
|
||||
|
||||
private readonly List<RobotError> Errors = [];
|
||||
|
||||
public void AddError(RobotError error, TimeSpan? clearAfter = null)
|
||||
{
|
||||
bool isFatal = false;
|
||||
lock (Errors)
|
||||
{
|
||||
if (Errors.Any(e => e.Id == error.Id)) return;
|
||||
Errors.Add(error);
|
||||
isFatal = error.ErrorLevel == ErrorLevel.FATAL;
|
||||
}
|
||||
if (isFatal) OnNewFatalError?.Invoke();
|
||||
if (clearAfter is not null && clearAfter.HasValue)
|
||||
{
|
||||
if (clearAfter.Value < TimeSpan.Zero) throw new ArgumentOutOfRangeException(nameof(clearAfter), "TimeSpan cannot be negative.");
|
||||
_ = Task.Run(async () =>
|
||||
{
|
||||
await Task.Delay(clearAfter.Value);
|
||||
lock (Errors)
|
||||
{
|
||||
Errors.RemoveAll(e => e.Id == error.Id);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
public void DeleteErrorType(string errorType)
|
||||
{
|
||||
lock (Errors)
|
||||
{
|
||||
Errors.RemoveAll(e => e.ErrorType == errorType);
|
||||
}
|
||||
}
|
||||
|
||||
public void DeleteErrorId(int id)
|
||||
{
|
||||
lock (Errors)
|
||||
{
|
||||
Errors.RemoveAll(e => e.Id == id);
|
||||
}
|
||||
}
|
||||
|
||||
public void ClearAllErrors()
|
||||
{
|
||||
lock (Errors)
|
||||
{
|
||||
Errors.Clear();
|
||||
}
|
||||
}
|
||||
|
||||
public void ClearFatalErrors()
|
||||
{
|
||||
lock (Errors) { Errors.RemoveAll(e => e.ErrorLevel == ErrorLevel.FATAL); }
|
||||
}
|
||||
|
||||
private static RobotError CreateError(int id, ErrorType type, string hint, ErrorLevel level, string description)
|
||||
{
|
||||
return new RobotError()
|
||||
{
|
||||
Id = id,
|
||||
ErrorType = type.ToString(),
|
||||
ErrorLevel = level,
|
||||
ErrorDescription = description,
|
||||
ErrorHint = hint,
|
||||
ErrorReferences = []
|
||||
};
|
||||
}
|
||||
|
||||
public static RobotError Error1001(string oldOrderId, string newOrderId)
|
||||
=> CreateError(1001, ErrorType.ORDER_ERROR, "Vui lòng kiểm tra lại OrderId", ErrorLevel.WARNING, $"Có order đang được thực hiện. OrderId: {oldOrderId}, OrderId mới: {newOrderId}");
|
||||
public static RobotError Error1002(int nodesLength)
|
||||
=> CreateError(1002, ErrorType.ORDER_ERROR, "Vui lòng kiểm tra lại kích thước Nodes", ErrorLevel.WARNING, $"Order Nodes không hợp lệ. Kích thước: {nodesLength}");
|
||||
public static RobotError Error1003(int oldOrderUpdateId, int newOrderUpdateId)
|
||||
=> CreateError(1003, ErrorType.ORDER_UPDATE_ERROR, "Vui lòng kiểm tra lại OrderUpdateId", ErrorLevel.WARNING, $"OrderUpdateId {newOrderUpdateId} nhận được nhỏ hơn OrderUpdateId hiện tại là {oldOrderUpdateId}");
|
||||
public static RobotError Error1004(int nodesLength, int edgesLength)
|
||||
=> CreateError(1004, ErrorType.ORDER_ERROR, "Vui lòng kiểm tra lại kích thước giữa Nodes và Edges", ErrorLevel.WARNING, $"Order không hợp lệ do kích thước giữa Nodes và Edges không phù hợp. Kích thước Edges: {edgesLength}, kích thước nodes: {nodesLength}");
|
||||
public static RobotError Error1005()
|
||||
=> CreateError(1005, ErrorType.ORDER_ERROR, "Vui lòng kiểm tra lại OrderId", ErrorLevel.WARNING, $"Không có order đang được thực hiện.");
|
||||
public static RobotError Error1006(string rootState)
|
||||
=> CreateError(1006, ErrorType.INITIALIZE_ORDER, "Vui lòng chờ robot sẵn sàng", ErrorLevel.WARNING, $"Robot chưa sẵn sàng để nhận Order. Trạng thái hiện tại {rootState}");
|
||||
public static RobotError Error1007()
|
||||
=> CreateError(1007, ErrorType.VALIDATION_ERROR, "Vui lòng chờ hoàn thành các action hiện tại.", ErrorLevel.WARNING, $"Không thể khởi tạo order mới khi có action đang thực hiện.");
|
||||
public static RobotError Error1008(string edgeId, string nodeId)
|
||||
=> CreateError(1008, ErrorType.ORDER_ERROR, "Vui lòng kiểm tra lại order", ErrorLevel.WARNING, $"Order có edge {edgeId} tồn tại startNode {nodeId} không nằm trong danh sách nodes");
|
||||
public static RobotError Error1009(string edgeId, string nodeId)
|
||||
=> CreateError(1009, ErrorType.ORDER_ERROR, "Vui lòng kiểm tra lại order", ErrorLevel.WARNING, $"Order có edge {edgeId} tồn tại endNode {nodeId} không nằm trong danh sách nodes");
|
||||
public static RobotError Error1010(string lastNodeId, string newStartNodeId)
|
||||
=> CreateError(1010, ErrorType.ORDER_ERROR, "Vui lòng kiểm tra lại order", ErrorLevel.WARNING, $"Order mới nhận được không phải là nối tiếp của order khi lastNodeId: {lastNodeId} mà node đầu tiên của order mới là: {newStartNodeId}");
|
||||
public static RobotError Error1011(int lastNodeSequenceId, int newStartNodeSequenceId)
|
||||
=> CreateError(1011, ErrorType.ORDER_ERROR, "Vui lòng kiểm tra lại order", ErrorLevel.WARNING, $"Order mới nhận được không phải là nối tiếp của order khi LastNodeSequenceId: {lastNodeSequenceId} mà node đầu tiên của order mới có sequence: {newStartNodeSequenceId}");
|
||||
public static RobotError Error1012(string nodeId, int sequenceId, int correctIndex)
|
||||
=> CreateError(1012, ErrorType.ORDER_ERROR, "Vui lòng kiểm tra lại order node sequence", ErrorLevel.WARNING, $"Order Nodes không đúng thứ tự. NodeId: {nodeId}, SequenceId: {sequenceId}, Vị trí đúng: {correctIndex}");
|
||||
public static RobotError Error1013(string edgeId, int sequenceId, int correctIndex)
|
||||
=> CreateError(1013, ErrorType.ORDER_ERROR, "Vui lòng kiểm tra lại order edge sequence", ErrorLevel.WARNING, $"Order Edges không đúng thứ tự. EdgeId: {edgeId}, SequenceId: {sequenceId}, Vị trí đúng: {correctIndex}");
|
||||
public static RobotError Error1014()
|
||||
=> CreateError(1014, ErrorType.ORDER_ERROR, "", ErrorLevel.WARNING, "Order kết thúc không thành công do module Navigation có lỗi xảy ra");
|
||||
public static RobotError Error1015(string nodeId)
|
||||
=> CreateError(1015, ErrorType.ORDER_ERROR, "Vui lòng kiểm tra lại order", ErrorLevel.WARNING, $"Order node {nodeId} yêu cầu phải có NodePosition");
|
||||
public static RobotError Error1016(string nodeId, double distance, double allowedDeviation)
|
||||
=> CreateError(1016, ErrorType.ORDER_ERROR, "Robot quá xa node bắt đầu", ErrorLevel.WARNING, $"Robot cách node bắt đầu {nodeId} quá xa. Khoảng cách: {distance:F2}m, cho phép: {allowedDeviation:F2}m");
|
||||
public static RobotError Error1017(string nodeId, double distance, double allowedDeviation)
|
||||
=> CreateError(1017, ErrorType.ORDER_ERROR, "Robot đã ở node đích", ErrorLevel.WARNING, $"Robot đã ở tại hoặc quá gần node đích {nodeId}. Khoảng cách: {distance:F2}m, tối thiểu: {allowedDeviation:F2}m");
|
||||
public static RobotError Error1018(string edgeId)
|
||||
=> CreateError(1018, ErrorType.ORDER_ERROR, "Vui lòng kiểm tra lại trajectory", ErrorLevel.WARNING, $"Edge {edgeId} có trajectory không hợp lệ: knotVector size phải bằng controlPoints + degree + 1");
|
||||
public static RobotError Error1019(string edgeId)
|
||||
=> CreateError(1019, ErrorType.ORDER_ERROR, "Vui lòng kiểm tra lại trajectory", ErrorLevel.WARNING, $"Edge {edgeId} có trajectory không hợp lệ: knotVector phải là dãy tăng dần từ 0 đến 1");
|
||||
public static RobotError Error1020(string edgeId)
|
||||
=> CreateError(1020, ErrorType.ORDER_ERROR, "Vui lòng kiểm tra lại trajectory", ErrorLevel.WARNING, $"Edge {edgeId} có trajectory không hợp lệ: cần ít nhất 2 controlPoints (điểm bắt đầu và kết thúc)");
|
||||
|
||||
public static RobotError Error2001()
|
||||
=> CreateError(2001, ErrorType.PERIPHERAL_ERROR, "", ErrorLevel.FATAL, "Có lỗi xảy ra trong quá trình đọc tín hiệu từ hệ thống ngoại vi(PLC)");
|
||||
public static RobotError Error2002()
|
||||
=> CreateError(2002, ErrorType.PERIPHERAL_ERROR, "", ErrorLevel.FATAL, "Có lỗi xảy ra trong quá trình gửi tín hiệu tới hệ thống ngoại vi(PLC)");
|
||||
public static RobotError Error2003()
|
||||
=> CreateError(2003, ErrorType.PERIPHERAL_ERROR, "", ErrorLevel.FATAL, "Mất kết nối với hệ thống ngoại vi(PLC)");
|
||||
|
||||
public static RobotError Error3001()
|
||||
=> CreateError(3001, ErrorType.LOCALIZATION_ERROR, "", ErrorLevel.WARNING, "Trạng thái định vị chưa sẵn sàng");
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
using RobotNet10.FleetManager.Events;
|
||||
using RobotNet10.RobotApp.Events;
|
||||
using RobotNet10.RobotApp.Interfaces;
|
||||
using RobotNet10.RobotApp.Services.ConfigManager;
|
||||
using RobotNet10.RobotApp.Services.Navigation;
|
||||
using RobotNet10.RobotApp.Services.Navigation.CSharp;
|
||||
using RobotNet10.RobotApp.Services.Robot.Actions;
|
||||
using RobotNet10.RobotApp.Services.Robot.Connection;
|
||||
using RobotNet10.RobotApp.Services.Robot.Modules;
|
||||
using RobotNet10.RobotApp.Services.State;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
|
||||
namespace RobotNet10.RobotApp.Services.Robot;
|
||||
|
||||
public static class RobotExtensions
|
||||
{
|
||||
public static IServiceCollection AddRobot(this IServiceCollection services)
|
||||
{
|
||||
services.AddSingleton<RobotStateMachine>();
|
||||
services.AddSingleton<RobotStateMachineExecute>();
|
||||
services.AddSingleton<RobotVisualization>();
|
||||
|
||||
services.AddInterfaceServiceSingleton<IRobotConfiguration, RobotConfiguration>();
|
||||
services.AddInterfaceServiceSingleton<IConnectionConfig, ConnectionConfig>();
|
||||
services.AddInterfaceServiceSingleton<INavigationConfig, ConfigManager.NavigationConfig>();
|
||||
services.AddInterfaceServiceSingleton<IRobotConnectionsService, RobotConnectionsService>();
|
||||
services.AddInterfaceServiceSingleton<IRobotEventBus, RobotEventBus>();
|
||||
services.AddInterfaceServiceSingleton<IError, RobotErrors>();
|
||||
services.AddInterfaceServiceSingleton<IInfomation, RobotInfomations>();
|
||||
services.AddInterfaceServiceSingleton<INavigation, RobotNavigation>();
|
||||
services.AddInterfaceServiceSingleton<IOrder, RobotOrderController>();
|
||||
services.AddInterfaceServiceSingleton<ILoad, RobotLoads>();
|
||||
services.AddInterfaceServiceSingleton<ILocalization, RobotLocalization>();
|
||||
services.AddInterfaceServiceSingleton<IState, RobotStates>();
|
||||
services.AddInterfaceServiceSingleton<IPlcController, RobotPlcController>();
|
||||
|
||||
services.AddInterfaceServiceSingleton<IVelocityController, VelocityController>();
|
||||
services.AddInterfaceServiceSingleton<IFactsheet, RobotFactsheet>();
|
||||
|
||||
services.AddHostedInterfaceServiceSingleton<IAction, RobotActionController>();
|
||||
services.AddHostedInterfaceServiceSingleton<IRobotActionProvider, RobotActionProvider>();
|
||||
|
||||
services.AddHostedInterfaceServiceSingleton<IRobotController, RobotController>();
|
||||
return services;
|
||||
}
|
||||
|
||||
public static IServiceCollection AddInterfaceServiceSingleton<TService, [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors)] TImplementation>(this IServiceCollection services) where TService : class where TImplementation : class, TService
|
||||
{
|
||||
services.AddSingleton<TImplementation>();
|
||||
services.AddSingleton<TService>(sp => sp.GetRequiredService<TImplementation>());
|
||||
return services;
|
||||
}
|
||||
|
||||
public static IServiceCollection AddInterfacesServiceSingleton<TService1, TService2, [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors)] TImplementation>(this IServiceCollection services) where TService1 : class where TService2 : class where TImplementation : class, TService1, TService2
|
||||
{
|
||||
services.AddSingleton<TImplementation>();
|
||||
services.AddSingleton<TService1>(sp => sp.GetRequiredService<TImplementation>());
|
||||
services.AddSingleton<TService2>(sp => sp.GetRequiredService<TImplementation>());
|
||||
return services;
|
||||
}
|
||||
|
||||
public static IServiceCollection AddHostedServiceSingleton<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors)] THostedService>(this IServiceCollection services) where THostedService : class, IHostedService
|
||||
{
|
||||
services.AddSingleton<THostedService>();
|
||||
services.AddHostedService(sp => sp.GetRequiredService<THostedService>());
|
||||
return services;
|
||||
}
|
||||
|
||||
public static IServiceCollection AddHostedInterfaceServiceSingleton<TService, [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors)] THostedService>(this IServiceCollection services) where TService : class where THostedService : class, IHostedService, TService
|
||||
{
|
||||
services.AddSingleton<THostedService>();
|
||||
services.AddSingleton<TService>(sp => sp.GetRequiredService<THostedService>());
|
||||
services.AddHostedService(sp => sp.GetRequiredService<THostedService>());
|
||||
return services;
|
||||
}
|
||||
|
||||
public static IServiceCollection AddHostedInterfaceServiceSingleton<TService1, TService2, [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors)] THostedService>(this IServiceCollection services) where TService1 : class where TService2 : class where THostedService : class, IHostedService, TService1, TService2
|
||||
{
|
||||
services.AddSingleton<THostedService>();
|
||||
services.AddSingleton<TService1>(sp => sp.GetRequiredService<THostedService>());
|
||||
services.AddSingleton<TService2>(sp => sp.GetRequiredService<THostedService>());
|
||||
services.AddHostedService(sp => sp.GetRequiredService<THostedService>());
|
||||
return services;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
using RobotNet.VDA5050;
|
||||
using RobotNet.VDA5050.Factsheet;
|
||||
using RobotNet.VDA5050.Type;
|
||||
using RobotNet10.RobotApp.Interfaces;
|
||||
using RobotNet10.RobotApp.Services.ConfigManager;
|
||||
using RobotNet10.RobotApp.Services.Robot.Connection;
|
||||
|
||||
namespace RobotNet10.RobotApp.Services.Robot;
|
||||
|
||||
public class RobotFactsheet(IConnectionConfig ConnectionConfig,
|
||||
IRobotConnectionsService RobotConnection,
|
||||
Logger<RobotFactsheet> Logger) : IFactsheet
|
||||
{
|
||||
public async Task PubFactsheet()
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!RobotConnection.IsConnected) return;
|
||||
|
||||
var vdaConfig = ConnectionConfig.GetVDA5050Config();
|
||||
FactSheetMsg factSheet = new()
|
||||
{
|
||||
SerialNumber = vdaConfig.SerialNumber,
|
||||
Manufacturer = vdaConfig.Manufacturer,
|
||||
Version = vdaConfig.Version,
|
||||
};
|
||||
await RobotConnection.PublishFactsheetAsync(factSheet);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.Error($"Error publishing factsheet: {ex.Message}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
using RobotNet.VDA5050.State;
|
||||
using RobotNet10.RobotApp.Interfaces;
|
||||
|
||||
namespace RobotNet10.RobotApp.Services.Robot;
|
||||
|
||||
public class RobotInfomations() : IInfomation
|
||||
{
|
||||
public Information[] InformationState => [.. Infors];
|
||||
private readonly List<Information> Infors = [];
|
||||
public void AddInfo(Information infor)
|
||||
{
|
||||
if (Infors.Any(e => e.InfoType == infor.InfoType)) return;
|
||||
lock (Infors)
|
||||
{
|
||||
Infors.Add(infor);
|
||||
}
|
||||
}
|
||||
|
||||
public void DeleteInfoType(string infoType)
|
||||
{
|
||||
lock (Infors)
|
||||
{
|
||||
Infors.RemoveAll(e => e.InfoType == infoType);
|
||||
}
|
||||
}
|
||||
|
||||
public void ClearAllInfos()
|
||||
{
|
||||
lock (Infors)
|
||||
{
|
||||
Infors.Clear();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
using RobotNet.VDA5050.State;
|
||||
using RobotNet10.RobotApp.Interfaces;
|
||||
|
||||
namespace RobotNet10.RobotApp.Services.Robot;
|
||||
|
||||
public class RobotLoads() : ILoad
|
||||
{
|
||||
public Load[] Load { get; private set; } = [];
|
||||
|
||||
private static Load GetLoad()
|
||||
{
|
||||
return new()
|
||||
{
|
||||
LoadId = Guid.NewGuid().ToString(),
|
||||
LoadDimensions = new RobotNet.VDA5050.Factsheet.LoadDimensions
|
||||
{
|
||||
Length = 0.5,
|
||||
Width = 0.5,
|
||||
Height = 0.5
|
||||
},
|
||||
LoadPosition = "on_top",
|
||||
LoadType = "box",
|
||||
BoundingBoxReference = new RobotNet.VDA5050.Factsheet.BoundingBoxReference
|
||||
{
|
||||
X = 0,
|
||||
Y = 0,
|
||||
Z = 0,
|
||||
},
|
||||
Weight = 999
|
||||
};
|
||||
}
|
||||
|
||||
public void AddLoad(Load load)
|
||||
{
|
||||
Load = [.. Load, GetLoad()];
|
||||
}
|
||||
|
||||
public void ClearLoad()
|
||||
{
|
||||
Load = [];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,969 @@
|
||||
using RobotNet.VDA5050.Order;
|
||||
using RobotNet.VDA5050.State;
|
||||
using RobotNet.VDA5050.Type;
|
||||
using RobotNet10.Common;
|
||||
using RobotNet10.RobotApp.Client.Pages;
|
||||
using RobotNet10.RobotApp.Interfaces;
|
||||
using RobotNet10.RobotApp.Services.ConfigManager;
|
||||
using RobotNet10.RobotApp.Services.Exceptions;
|
||||
using RobotNet10.RobotApp.Services.State;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Data;
|
||||
using Action = RobotNet.VDA5050.InstantAction.Action;
|
||||
|
||||
namespace RobotNet10.RobotApp.Services.Robot;
|
||||
|
||||
public class RobotOrderController(INavigation NavigationManager,
|
||||
ILocalization Localization,
|
||||
IAction ActionManager,
|
||||
IError ErrorManager,
|
||||
IPlcController SafetyManager,
|
||||
RobotStateMachine StateManager,
|
||||
INavigationConfig NavigationConfig,
|
||||
ILogger<RobotOrderController> Logger) : IOrder
|
||||
{
|
||||
public string OrderId { get; private set; } = string.Empty;
|
||||
public int OrderUpdateId { get; private set; }
|
||||
public NodeState[] NodeStates { get; private set; } = [];
|
||||
public EdgeState[] EdgeStates { get; private set; } = [];
|
||||
public string LastNodeId => LastNode is null ? "" : LastNode.NodeId;
|
||||
public int LastNodeSequenceId => LastNode is null ? 0 : LastNode.SequenceId;
|
||||
public bool NewBaseRequest { get; private set; }
|
||||
public double DistanceSinceLastNode { get; private set; }
|
||||
public bool IsPaused { get; private set; } = false;
|
||||
|
||||
private const int CycleHandlerMilliseconds = 100;
|
||||
private WatchThread<RobotOrderController>? OrderTimer;
|
||||
|
||||
private readonly Dictionary<string, Action[]> OrderActions = []; // Node actions keyed by NodeId
|
||||
private readonly ConcurrentQueue<Action> ActionWaitingRunning = [];
|
||||
|
||||
private OrderMsg? NewOrder;
|
||||
private OrderMsg? _currentActiveOrder;
|
||||
private Node[] Nodes = [];
|
||||
private Edge[] Edges = [];
|
||||
private Node? CurrentBaseNode;
|
||||
private Node? LastNode;
|
||||
private Edge? CurrentEdge; // Track current edge for EDGE action lifecycle
|
||||
private readonly ConcurrentBag<string> RunningEdgeActionIds = []; // Track running EDGE action IDs (thread-safe)
|
||||
|
||||
private readonly Lock LockObject = new();
|
||||
|
||||
private bool IsCancelOrder = false;
|
||||
private bool IsCancelSentToNavigation = false;
|
||||
private bool IsActionRunning = false;
|
||||
private bool IsWaitingPaused = false;
|
||||
private bool IsNavigationFinished = false;
|
||||
private bool HasNewOrder = false;
|
||||
private Action? ActionHard = null;
|
||||
private NavigationState NavState = NavigationState.None;
|
||||
|
||||
private double SafetySpeed = 0.0;
|
||||
private double EdgeSpeed = 0.0;
|
||||
private double CurrentSpeed = 0.0;
|
||||
private Navigation.NavigationConfig? CachedNavConfig = null;
|
||||
|
||||
public void UpdateOrder(OrderMsg order)
|
||||
{
|
||||
bool shouldStart = false;
|
||||
lock (LockObject)
|
||||
{
|
||||
NewOrder = order;
|
||||
if (OrderTimer is null)
|
||||
{
|
||||
shouldStart = true;
|
||||
}
|
||||
}
|
||||
if (shouldStart) HandleOrderStart();
|
||||
}
|
||||
|
||||
public void StopOrder()
|
||||
{
|
||||
if (NodeStates.Length > 0 || OrderTimer is not null)
|
||||
{
|
||||
IsCancelOrder = true;
|
||||
IsCancelSentToNavigation = false;
|
||||
}
|
||||
}
|
||||
|
||||
public void PauseOrder()
|
||||
{
|
||||
IsPaused = true;
|
||||
NavigationManager.Pause();
|
||||
ActionManager.PauseActions();
|
||||
}
|
||||
|
||||
public void ResumeOrder()
|
||||
{
|
||||
IsPaused = false;
|
||||
NavigationManager.Resume();
|
||||
ActionManager.ResumeActions();
|
||||
}
|
||||
|
||||
private void HandleOrderStart()
|
||||
{
|
||||
// Console.WriteLine("HandleOrderStart called");
|
||||
OrderTimer = new(CycleHandlerMilliseconds, OrderHandler, Logger);
|
||||
OrderTimer.Start();
|
||||
}
|
||||
|
||||
private void HandleOrderStop()
|
||||
{
|
||||
OrderTimer?.Dispose();
|
||||
OrderTimer = null;
|
||||
OrderActions.Clear();
|
||||
ActionWaitingRunning.Clear();
|
||||
ActionManager.StopOrderAction(); // Stop all running order actions
|
||||
// Reset state flags
|
||||
IsCancelOrder = false;
|
||||
IsCancelSentToNavigation = false;
|
||||
IsNavigationFinished = false;
|
||||
IsActionRunning = false;
|
||||
IsWaitingPaused = false;
|
||||
IsPaused = false;
|
||||
ActionHard = null;
|
||||
CurrentBaseNode = null;
|
||||
Nodes = [];
|
||||
Edges = [];
|
||||
_currentActiveOrder = null;
|
||||
// Reset EDGE action tracking
|
||||
CurrentEdge = null;
|
||||
RunningEdgeActionIds.Clear();
|
||||
// Reset speed tracking
|
||||
SafetySpeed = 0.0;
|
||||
EdgeSpeed = 0.0;
|
||||
CurrentSpeed = 0.0;
|
||||
CachedNavConfig = null;
|
||||
UpdateState();
|
||||
SafetyManager.OnSafetySpeedChanged -= OnSafetySpeedChanged;
|
||||
NavigationManager.OnNavigationFinished -= NavigationFinished;
|
||||
StateManager.Fire(RobotEventType.CompleteExecution);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// VDA5050 Compliance: Normalize angle to range [-π, π]
|
||||
/// </summary>
|
||||
private static double NormalizeAngle(double angle)
|
||||
{
|
||||
while (angle > Math.PI) angle -= 2 * Math.PI;
|
||||
while (angle < -Math.PI) angle += 2 * Math.PI;
|
||||
return angle;
|
||||
}
|
||||
|
||||
private Node? GetCurrentNode()
|
||||
{
|
||||
Node? inNode = null;
|
||||
double minDistance = double.MaxValue;
|
||||
foreach (var node in Nodes)
|
||||
{
|
||||
var distance = Localization.DistanceTo(node.NodePosition?.X ?? 0, node.NodePosition?.Y ?? 0);
|
||||
var nodeMin = node.NodePosition?.AllowedDeviationXY == 0.0 ? 0.5 : node.NodePosition?.AllowedDeviationXY ?? 0.3;
|
||||
|
||||
bool positionMatch = distance <= nodeMin;
|
||||
bool orientationMatch = true;
|
||||
|
||||
// VDA5050 Compliance: Check theta if specified
|
||||
if (node.NodePosition?.Theta is not null)
|
||||
{
|
||||
var currentTheta = Localization.Theta;
|
||||
var targetTheta = node.NodePosition.Theta.Value;
|
||||
var allowedThetaDev = node.NodePosition.AllowedDeviationTheta ?? Math.PI; // Default: any orientation
|
||||
|
||||
var thetaDiff = Math.Abs(NormalizeAngle(currentTheta - targetTheta));
|
||||
orientationMatch = thetaDiff <= allowedThetaDev;
|
||||
}
|
||||
|
||||
if (positionMatch && orientationMatch)
|
||||
{
|
||||
// Exclude last node - it's handled separately in HandleOrder() after navigation completes (lines 392-399)
|
||||
// This ensures intermediate node actions are processed during navigation
|
||||
if (distance < minDistance && node.NodeId != Nodes[^1].NodeId)
|
||||
{
|
||||
minDistance = distance;
|
||||
inNode = node;
|
||||
}
|
||||
}
|
||||
}
|
||||
return inNode;
|
||||
}
|
||||
|
||||
private void NavigationFinished(NavigationState state)
|
||||
{
|
||||
NavState = state;
|
||||
IsNavigationFinished = true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Xử lý sự kiện thay đổi safety speed từ PLC
|
||||
/// </summary>
|
||||
private void OnSafetySpeedChanged(SafetySpeed safetySpeed)
|
||||
{
|
||||
if (TryGetSafetySpeedFromPlcSignal(safetySpeed, out double safeSpeed))
|
||||
{
|
||||
if (safetySpeed == Interfaces.SafetySpeed.Very_Fast)
|
||||
{
|
||||
SafetySpeed = 0.0; // 0 = không giới hạn từ safety; edge/config quyết định trong UpdateNavigationSpeed
|
||||
UpdateNavigationSpeed();
|
||||
Logger.LogInformation("SafetySpeed released: robot speed now follows edge/config limit");
|
||||
}
|
||||
else
|
||||
{
|
||||
SafetySpeed = safeSpeed;
|
||||
UpdateNavigationSpeed();
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
Logger.LogWarning("Cannot map PLC SafetySpeed {SafetySpeed} to navigation speed value", safetySpeed);
|
||||
}
|
||||
}
|
||||
|
||||
private bool TryGetSafetySpeedFromPlcSignal(SafetySpeed safetySpeed, out double speed)
|
||||
{
|
||||
// PLC IO tốc độ: 825/2873, 826/2874, 827/2875, 828/2876.
|
||||
// Ưu tiên map cứng theo yêu cầu vận hành để không phụ thuộc config.
|
||||
switch (safetySpeed)
|
||||
{
|
||||
case Interfaces.SafetySpeed.Very_Slow:
|
||||
speed = 1.0;
|
||||
return true;
|
||||
case Interfaces.SafetySpeed.Slow:
|
||||
speed = 0.3;
|
||||
return true;
|
||||
case Interfaces.SafetySpeed.Normal:
|
||||
speed = 0.6;
|
||||
return true;
|
||||
case Interfaces.SafetySpeed.Medium:
|
||||
speed = 0.9;
|
||||
return true;
|
||||
case Interfaces.SafetySpeed.Very_Fast:
|
||||
speed = 1.5;
|
||||
return true;
|
||||
}
|
||||
|
||||
// Các mức còn lại vẫn theo cấu hình hiện tại.
|
||||
CachedNavConfig ??= NavigationConfig.GetNavigationConfig();
|
||||
if (CachedNavConfig.SafetySpeedMap.TryGetValue(safetySpeed, out double configSpeed))
|
||||
{
|
||||
speed = configSpeed;
|
||||
return true;
|
||||
}
|
||||
|
||||
speed = 0.0;
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// VDA5050 Compliance: Tính toán và áp dụng tốc độ giới hạn cho navigation
|
||||
/// Kết hợp 3 nguồn tốc độ: Edge, Safety, Config Max
|
||||
/// Sử dụng giá trị MIN để đảm bảo an toàn
|
||||
/// </summary>
|
||||
private void UpdateNavigationSpeed()
|
||||
{
|
||||
if (Nodes.Length <= 0 || IsNavigationFinished)
|
||||
return;
|
||||
|
||||
CachedNavConfig ??= NavigationConfig.GetNavigationConfig();
|
||||
double maxConfigSpeed = CachedNavConfig.MaxLinearVelocity;
|
||||
if (maxConfigSpeed <= 0)
|
||||
return;
|
||||
|
||||
// Bắt đầu từ max config; EdgeSpeed/SafetySpeed = 0 nghĩa là không giới hạn từ nguồn đó
|
||||
double targetSpeed = maxConfigSpeed;
|
||||
|
||||
if (EdgeSpeed > 0)
|
||||
targetSpeed = Math.Min(targetSpeed, EdgeSpeed);
|
||||
|
||||
if (SafetySpeed > 0)
|
||||
targetSpeed = Math.Min(targetSpeed, SafetySpeed);
|
||||
|
||||
targetSpeed = Math.Max(targetSpeed, CachedNavConfig.MinLinearVelocity);
|
||||
|
||||
if (Math.Abs(CurrentSpeed - targetSpeed) < 0.01)
|
||||
return;
|
||||
|
||||
CurrentSpeed = targetSpeed;
|
||||
NavigationManager.SetSpeed(CurrentSpeed);
|
||||
|
||||
Logger.LogInformation(
|
||||
"Speed updated: {CurrentSpeed:F2} m/s [Edge: {EdgeSpeed:F2}, Safety: {SafetySpeed:F2}, Max: {MaxSpeed:F2}]",
|
||||
CurrentSpeed,
|
||||
EdgeSpeed > 0 ? EdgeSpeed : maxConfigSpeed,
|
||||
SafetySpeed > 0 ? SafetySpeed : maxConfigSpeed,
|
||||
maxConfigSpeed);
|
||||
}
|
||||
|
||||
private void UpdateState()
|
||||
{
|
||||
NodeStates = [.. Nodes.Select(n => new NodeState
|
||||
{
|
||||
NodeId = n.NodeId,
|
||||
Released = n.Released,
|
||||
SequenceId = n.SequenceId,
|
||||
NodeDescription = n.NodeDescription,
|
||||
NodePosition = n.NodePosition is null ? null : new()
|
||||
{
|
||||
X = n.NodePosition.X,
|
||||
Y = n.NodePosition.Y,
|
||||
Theta = n.NodePosition.Theta,
|
||||
MapId = n.NodePosition.MapId
|
||||
}
|
||||
})];
|
||||
EdgeStates = [.. Edges.Select(e => new EdgeState
|
||||
{
|
||||
EdgeId = e.EdgeId,
|
||||
Released = e.Released,
|
||||
EdgeDescription = e.EdgeDescription,
|
||||
SequenceId = e.SequenceId,
|
||||
Trajectory = e.Trajectory
|
||||
})];
|
||||
}
|
||||
|
||||
private async Task ClearOldOrder()
|
||||
{
|
||||
OrderActions.Clear();
|
||||
await ActionManager.ClearActions();
|
||||
IsNavigationFinished = false;
|
||||
IsCancelOrder = false;
|
||||
IsActionRunning = false;
|
||||
IsWaitingPaused = false;
|
||||
ActionHard = null;
|
||||
}
|
||||
|
||||
private void AddAction(Action[] actions, Node node)
|
||||
{
|
||||
foreach (var item in actions)
|
||||
{
|
||||
item.ActionDescription += $".On Node: {(string.IsNullOrEmpty(node.NodeDescription) ? node.NodeId : node.NodeDescription)}";
|
||||
}
|
||||
if (OrderActions.TryGetValue(node.NodeId, out Action[]? oldActions) && oldActions is not null)
|
||||
{
|
||||
OrderActions[node.NodeId] = [.. oldActions, .. actions];
|
||||
}
|
||||
else OrderActions.Add(node.NodeId, actions);
|
||||
}
|
||||
|
||||
private void AddEdgeAction(Action[] actions, Edge edge)
|
||||
{
|
||||
foreach (var item in actions)
|
||||
{
|
||||
item.ActionDescription += $".On Edge: {(string.IsNullOrEmpty(edge.EdgeDescription) ? edge.EdgeId : edge.EdgeDescription)}";
|
||||
}
|
||||
if (OrderActions.TryGetValue(edge.EdgeId, out Action[]? oldActions) && oldActions is not null)
|
||||
{
|
||||
OrderActions[edge.EdgeId] = [.. oldActions, .. actions];
|
||||
}
|
||||
else OrderActions.Add(edge.EdgeId, actions);
|
||||
}
|
||||
|
||||
private void ValidateNodes(Node[] nodes, int currentSequence)
|
||||
{
|
||||
for (int i = 0; i < nodes.Length; i++)
|
||||
{
|
||||
int correctSequence = i * 2 + currentSequence;
|
||||
if (nodes[i].SequenceId != correctSequence) throw new OrderException(RobotErrors.Error1012(nodes[i].NodeId, nodes[i].SequenceId, correctSequence));
|
||||
if (nodes[i].NodePosition is null) throw new OrderException(RobotErrors.Error1015(nodes[i].NodeId));
|
||||
if (i == 0)
|
||||
{
|
||||
if (nodes[i].Released)
|
||||
{
|
||||
if (nodes[i].Actions != null && nodes[i].Actions.Length > 0) AddAction(nodes[i].Actions, nodes[i]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void ValidateTrajectory(Edge edge, Node startNode, Node endNode)
|
||||
{
|
||||
// VDA5050 Compliance: Validate NURBS trajectory structure
|
||||
if (edge.Trajectory is not null)
|
||||
{
|
||||
var traj = edge.Trajectory;
|
||||
|
||||
// Validate controlPoints count (minimum 2: start and end)
|
||||
if (traj.ControlPoints is null || traj.ControlPoints.Length < 2)
|
||||
{
|
||||
throw new OrderException(RobotErrors.Error1020(edge.EdgeId));
|
||||
}
|
||||
|
||||
// Validate knotVector size: must equal controlPoints.Length + degree + 1
|
||||
if (traj.KnotVector is not null)
|
||||
{
|
||||
int expectedSize = traj.ControlPoints.Length + traj.Degree + 1;
|
||||
if (traj.KnotVector.Length != expectedSize)
|
||||
{
|
||||
throw new OrderException(RobotErrors.Error1018(edge.EdgeId));
|
||||
}
|
||||
|
||||
// Validate knotVector is monotonically increasing from 0 to 1
|
||||
for (int j = 0; j < traj.KnotVector.Length; j++)
|
||||
{
|
||||
if (traj.KnotVector[j] < 0 || traj.KnotVector[j] > 1)
|
||||
{
|
||||
throw new OrderException(RobotErrors.Error1019(edge.EdgeId));
|
||||
}
|
||||
if (j > 0 && traj.KnotVector[j] < traj.KnotVector[j - 1])
|
||||
{
|
||||
throw new OrderException(RobotErrors.Error1019(edge.EdgeId));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
// VDA5050 Compliance: Create valid default linear trajectory
|
||||
edge.Trajectory = new Trajectory()
|
||||
{
|
||||
Degree = 1,
|
||||
ControlPoints =
|
||||
[
|
||||
new ControlPoint()
|
||||
{
|
||||
X = startNode.NodePosition?.X ?? 0,
|
||||
Y = startNode.NodePosition?.Y ?? 0,
|
||||
Weight = 1.0
|
||||
},
|
||||
new ControlPoint()
|
||||
{
|
||||
X = endNode.NodePosition?.X ?? 0,
|
||||
Y = endNode.NodePosition?.Y ?? 0,
|
||||
Weight = 1.0
|
||||
}
|
||||
],
|
||||
KnotVector = [0, 0, 1, 1]
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private void ValidateEdges(Edge[] edges, Node[] nodes, int currentSequence)
|
||||
{
|
||||
for (int i = 0; i < edges.Length; i++)
|
||||
{
|
||||
var startNode = nodes.FirstOrDefault(n => n.NodeId == edges[i].StartNodeId) ??
|
||||
throw new OrderException(RobotErrors.Error1008(edges[i].EdgeId, edges[i].StartNodeId));
|
||||
var endNode = nodes.FirstOrDefault(n => n.NodeId == edges[i].EndNodeId) ??
|
||||
throw new OrderException(RobotErrors.Error1009(edges[i].EdgeId, edges[i].StartNodeId));
|
||||
|
||||
int correctSequence = i * 2 + 1 + currentSequence;
|
||||
if (edges[i].SequenceId != correctSequence) throw new OrderException(RobotErrors.Error1013(edges[i].EdgeId, edges[i].SequenceId, correctSequence));
|
||||
|
||||
// VDA5050 Compliance: Validate or create proper trajectory
|
||||
ValidateTrajectory(edges[i], startNode, endNode);
|
||||
|
||||
if (edges[i].Released)
|
||||
{
|
||||
if (endNode.Released)
|
||||
{
|
||||
CurrentBaseNode = endNode;
|
||||
if (endNode.Actions != null && endNode.Actions.Length > 0) AddAction(endNode.Actions, endNode);
|
||||
if (edges[i].Actions != null && edges[i].Actions.Length > 0) AddEdgeAction(edges[i].Actions, edges[i]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async Task HandleNewOrder(OrderMsg order)
|
||||
{
|
||||
if (order.OrderId == OrderId)
|
||||
{
|
||||
if (order.OrderUpdateId < OrderUpdateId) throw new OrderException(RobotErrors.Error1003(OrderUpdateId, order.OrderUpdateId));
|
||||
if (order.OrderUpdateId == OrderUpdateId) return;
|
||||
if (order.Nodes[0].NodeId != LastNodeId)
|
||||
{
|
||||
throw new OrderException(RobotErrors.Error1010(LastNodeId, order.Nodes[0].NodeId));
|
||||
}
|
||||
if (order.Nodes[0].SequenceId != LastNodeSequenceId)
|
||||
{
|
||||
throw new OrderException(RobotErrors.Error1011(LastNodeSequenceId, order.Nodes[0].SequenceId));
|
||||
}
|
||||
}
|
||||
|
||||
// xử lí order mới
|
||||
// Validate Nodes, Edges
|
||||
await ClearOldOrder();
|
||||
ValidateNodes(order.Nodes, order.OrderId == OrderId ? LastNodeSequenceId : 0);
|
||||
ValidateEdges(order.Edges, order.Nodes, order.OrderId == OrderId ? LastNodeSequenceId : 0);
|
||||
|
||||
// Add actions to ActionManager with correct scope
|
||||
if (OrderActions.Count > 0)
|
||||
{
|
||||
foreach (var actions in OrderActions)
|
||||
{
|
||||
ActionManager.AddOrderActions(actions.Value, order.Edges.Any(e => e.EdgeId == actions.Key) ? ActionScope.EDGE : ActionScope.NODE);
|
||||
}
|
||||
}
|
||||
|
||||
if (order.Nodes.Length <= 1 || order.Edges.Length == 0)
|
||||
{
|
||||
if (order.Nodes.Length == 1 && order.Nodes[0].Actions.Length == 0) return;
|
||||
NavigationFinished(NavigationState.Completed);
|
||||
}
|
||||
|
||||
OrderId = order.OrderId;
|
||||
OrderUpdateId = order.OrderUpdateId;
|
||||
Nodes = order.Nodes;
|
||||
Edges = order.Edges;
|
||||
_currentActiveOrder = order;
|
||||
ErrorManager.DeleteErrorType(ErrorType.VALIDATION_ERROR.ToString());
|
||||
ErrorManager.DeleteErrorType(ErrorType.ORDER_ERROR.ToString());
|
||||
ErrorManager.DeleteErrorType(ErrorType.ORDER_UPDATE_ERROR.ToString());
|
||||
UpdateState();
|
||||
HasNewOrder = true;
|
||||
}
|
||||
|
||||
private void ClearLastNode()
|
||||
{
|
||||
if (LastNode is null) return;
|
||||
var currentLastNodeIndex = Array.FindIndex(Nodes, n => n.NodeId == LastNode.NodeId);
|
||||
if (currentLastNodeIndex != -1 && currentLastNodeIndex < Nodes.Length - 1)
|
||||
{
|
||||
Nodes = [.. Nodes.Skip(currentLastNodeIndex + 1)];
|
||||
Edges = [.. Edges.Skip(currentLastNodeIndex + 1)];
|
||||
UpdateState();
|
||||
}
|
||||
}
|
||||
|
||||
private void HandleUpdateOrder(OrderMsg order)
|
||||
{
|
||||
if (order.OrderId != OrderId) throw new OrderException(RobotErrors.Error1001(OrderId, order.OrderId));
|
||||
if (order.OrderUpdateId < OrderUpdateId) throw new OrderException(RobotErrors.Error1003(OrderUpdateId, order.OrderUpdateId));
|
||||
if (order.OrderUpdateId == OrderUpdateId) return;
|
||||
|
||||
if (CurrentBaseNode is not null && order.Nodes[0].NodeId != CurrentBaseNode.NodeId)
|
||||
{
|
||||
throw new OrderException(RobotErrors.Error1010(LastNodeId, order.Nodes[0].NodeId));
|
||||
}
|
||||
if (CurrentBaseNode is not null && order.Nodes[0].SequenceId != CurrentBaseNode.SequenceId)
|
||||
{
|
||||
throw new OrderException(RobotErrors.Error1011(LastNodeSequenceId, order.Nodes[0].SequenceId));
|
||||
}
|
||||
|
||||
IsNavigationFinished = false;
|
||||
|
||||
Node[] baseNodes = CurrentBaseNode is null ? [] : [.. Nodes.TakeWhile(n => n != CurrentBaseNode).Append(CurrentBaseNode)];
|
||||
Edge[] baseEdges = CurrentBaseNode is null ? [] : [.. Edges.ToList().GetRange(0, baseNodes.Length - 1)];
|
||||
|
||||
ValidateNodes(order.Nodes, baseNodes.Length > 0 ? baseNodes[^1].SequenceId : 0);
|
||||
ValidateEdges(order.Edges, order.Nodes, baseNodes.Length > 0 ? baseNodes[^1].SequenceId : 0);
|
||||
|
||||
if (OrderActions.Count > 0)
|
||||
{
|
||||
foreach(var actions in OrderActions)
|
||||
{
|
||||
ActionManager.AddOrderActions(actions.Value, order.Edges.Any(e => e.EdgeId == actions.Key) ? ActionScope.EDGE : ActionScope.NODE);
|
||||
}
|
||||
}
|
||||
|
||||
OrderUpdateId = order.OrderUpdateId;
|
||||
Nodes = [.. baseNodes, .. order.Nodes.Skip(1)];
|
||||
Edges = [.. baseEdges, .. order.Edges];
|
||||
_currentActiveOrder = new OrderMsg
|
||||
{
|
||||
HeaderId = order.HeaderId,
|
||||
Timestamp = order.Timestamp,
|
||||
Version = order.Version,
|
||||
Manufacturer = order.Manufacturer,
|
||||
SerialNumber = order.SerialNumber,
|
||||
OrderId = order.OrderId,
|
||||
OrderUpdateId = order.OrderUpdateId,
|
||||
ZoneSetId = order.ZoneSetId,
|
||||
Nodes = Nodes,
|
||||
Edges = Edges
|
||||
};
|
||||
|
||||
ErrorManager.DeleteErrorType(ErrorType.VALIDATION_ERROR.ToString());
|
||||
ErrorManager.DeleteErrorType(ErrorType.ORDER_ERROR.ToString());
|
||||
ErrorManager.DeleteErrorType(ErrorType.ORDER_UPDATE_ERROR.ToString());
|
||||
UpdateState();
|
||||
}
|
||||
|
||||
private void StartActionTerminal(Node node)
|
||||
{
|
||||
var action = node.Actions[0];
|
||||
var robotAction = ActionManager[action.ActionId];
|
||||
if (robotAction is null)
|
||||
{
|
||||
if (!ActionManager.HasActionWaitting && node.Actions.Length > 0) node.Actions = [.. node.Actions.Skip(1)];
|
||||
return;
|
||||
}
|
||||
if (robotAction.IsCompleted) node.Actions = [.. node.Actions.Skip(1)];
|
||||
if (robotAction.Status == ActionStatus.WAITING) ActionManager.StartOrderAction(action.ActionId);
|
||||
}
|
||||
|
||||
private void HandleOrder()
|
||||
{
|
||||
if (Nodes.Length <= 0)
|
||||
{
|
||||
HandleOrderStop();
|
||||
return;
|
||||
}
|
||||
|
||||
if (HasNewOrder)
|
||||
{
|
||||
if (ActionManager.HasActionWaitting) return;
|
||||
if (Nodes.Length > 1 && Edges.Length >= 0)
|
||||
{
|
||||
if (Nodes[0].Actions.Length > 0)
|
||||
{
|
||||
// VDA5050: Check if robot is on Node[0] before triggering actions
|
||||
var startNode = Nodes[0];
|
||||
var nodeDeviation = startNode.NodePosition?.AllowedDeviationXY ?? 0.5;
|
||||
if (nodeDeviation == 0.0) nodeDeviation = 0.5;
|
||||
var distance = Localization.DistanceTo(startNode.NodePosition?.X ?? 0, startNode.NodePosition?.Y ?? 0);
|
||||
|
||||
if (distance <= nodeDeviation)
|
||||
{
|
||||
// VDA5050: Separate NONE from blocking actions on Node[0]
|
||||
var noneActions = startNode.Actions.Where(a => a.BlockingType == BlockingType.NONE).ToArray();
|
||||
var blockingActions = startNode.Actions.Where(a => a.BlockingType != BlockingType.NONE).ToArray();
|
||||
|
||||
// Start NONE actions immediately - they must not delay navigation
|
||||
foreach (var action in noneActions)
|
||||
{
|
||||
ActionManager.StartOrderAction(action.ActionId);
|
||||
}
|
||||
|
||||
startNode.Actions = blockingActions;
|
||||
|
||||
if (blockingActions.Length > 0)
|
||||
{
|
||||
// Robot is on Node[0] - trigger blocking actions sequentially
|
||||
StartActionTerminal(Nodes[0]);
|
||||
return;
|
||||
}
|
||||
// All were NONE → fall through to start navigation
|
||||
}
|
||||
// else: Robot not on Node[0] - let navigation start, actions will trigger when node is traversed
|
||||
}
|
||||
else
|
||||
// Start navigation (no blocking actions on Node[0], or Node[0] has no actions)
|
||||
{
|
||||
IsCancelSentToNavigation = false;
|
||||
NavigationManager.OnNavigationFinished += NavigationFinished;
|
||||
SafetyManager.OnSafetySpeedChanged += OnSafetySpeedChanged;
|
||||
|
||||
// Cache NavigationConfig để tránh load lại mỗi lần speed change
|
||||
CachedNavConfig = NavigationConfig.GetNavigationConfig();
|
||||
|
||||
// VDA5050: Đọc initial safety speed khi bắt đầu navigation
|
||||
var currentSafetySpeed = SafetyManager.SafetySpeed;
|
||||
if (TryGetSafetySpeedFromPlcSignal(currentSafetySpeed, out double safeSpeed))
|
||||
{
|
||||
SafetySpeed = safeSpeed;
|
||||
Logger.LogInformation("Initial safety speed: {SafetySpeed:F2} m/s (level: {Level})", SafetySpeed, currentSafetySpeed);
|
||||
}
|
||||
|
||||
// VDA5050: Set initial edge speed (edge đầu tiên)
|
||||
if (Edges.Length > 0 && Edges[0].MaxSpeed.HasValue && Edges[0].MaxSpeed is double speed)
|
||||
{
|
||||
EdgeSpeed = speed;
|
||||
Logger.LogInformation("Initial edge speed: {EdgeSpeed:F2} m/s (edge: {EdgeId})", EdgeSpeed, Edges[0].EdgeId);
|
||||
}
|
||||
else
|
||||
{
|
||||
EdgeSpeed = 0.0; // Không giới hạn
|
||||
}
|
||||
|
||||
// VDA5050: Áp dụng tốc độ ban đầu trước khi bắt đầu navigation
|
||||
UpdateNavigationSpeed();
|
||||
|
||||
// chỗ này có thể sẽ phải sửa lại theo interface của a Hiệp
|
||||
NavigationManager.Move(_currentActiveOrder!, SafetyManager.SetHasLoadValue);
|
||||
if (CurrentBaseNode is not null
|
||||
&& CurrentBaseNode.NodeId != Nodes[0].NodeId
|
||||
&& CurrentBaseNode.NodeId != Nodes[^1].NodeId
|
||||
&& Nodes.Length > 1)
|
||||
{
|
||||
NavigationManager.UpdateOrder(CurrentBaseNode.NodeId);
|
||||
}
|
||||
if (StateManager.CurrentState != RobotStateType.Executing) StateManager.Fire(RobotEventType.StartExecution);
|
||||
if(OrderActions.ContainsKey(Nodes[0].NodeId)) OrderActions.Remove(Nodes[0].NodeId);
|
||||
HasNewOrder = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (IsCancelOrder && !IsCancelSentToNavigation)
|
||||
{
|
||||
NavigationManager.CancelMovement();
|
||||
IsCancelSentToNavigation = true;
|
||||
}
|
||||
|
||||
if (IsNavigationFinished)
|
||||
{
|
||||
if (IsCancelOrder && !ActionManager.HasActionRunning)
|
||||
{
|
||||
HandleOrderStop();
|
||||
Logger.LogInformation("Order {OrderId} is canceled", OrderId);
|
||||
}
|
||||
else if (NavState == NavigationState.Completed)
|
||||
{
|
||||
if (Nodes.Length > 0 && Nodes[^1].Actions.Length > 0) StartActionTerminal(Nodes[^1]);
|
||||
else if (ActionManager.HasActionRunning) return;
|
||||
else
|
||||
{
|
||||
LastNode = Nodes[^1];
|
||||
HandleOrderStop();
|
||||
Logger.LogInformation("Order {OrderId} is finished", OrderId);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
if (NavState == NavigationState.Error) ErrorManager.AddError(RobotErrors.Error1014());
|
||||
HandleOrderStop();
|
||||
Logger.LogInformation("Order {OrderId} is error", OrderId);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
var currentNode = GetCurrentNode();
|
||||
if (currentNode is not null && currentNode.NodeId != LastNode?.NodeId)
|
||||
{
|
||||
LastNode = currentNode;
|
||||
|
||||
// VDA5050 Section 6.10.2: Finish EDGE actions from previous edge when leaving it
|
||||
if (CurrentEdge is not null && !RunningEdgeActionIds.IsEmpty)
|
||||
{
|
||||
Logger.LogInformation("Finishing {Count} EDGE actions from edge {EdgeId}", RunningEdgeActionIds.Count, CurrentEdge.EdgeId);
|
||||
foreach (var actionId in RunningEdgeActionIds.ToList())
|
||||
{
|
||||
ActionManager.FinishAction(actionId);
|
||||
}
|
||||
RunningEdgeActionIds.Clear();
|
||||
}
|
||||
|
||||
// VDA5050: Cập nhật edge speed và start EDGE actions khi robot vào edge tiếp theo
|
||||
// Khi đến node i, robot sẽ bắt đầu đi trên edge i (từ node i → node i+1)
|
||||
var currentNodeIndex = Array.FindIndex(Nodes, n => n.NodeId == currentNode.NodeId);
|
||||
if (currentNodeIndex >= 0 && currentNodeIndex < Edges.Length)
|
||||
{
|
||||
var nextEdge = Edges[currentNodeIndex];
|
||||
CurrentEdge = nextEdge;
|
||||
|
||||
// Update edge speed
|
||||
if (nextEdge.MaxSpeed.HasValue && nextEdge.MaxSpeed.Value > 0)
|
||||
{
|
||||
EdgeSpeed = nextEdge.MaxSpeed.Value;
|
||||
Logger.LogInformation("Edge speed updated: {EdgeSpeed:F2} m/s (edge: {EdgeId}, node: {NodeId})",
|
||||
EdgeSpeed, nextEdge.EdgeId, currentNode.NodeId);
|
||||
}
|
||||
else
|
||||
{
|
||||
EdgeSpeed = 0.0; // Không giới hạn
|
||||
Logger.LogInformation("Edge speed limit removed (edge: {EdgeId}, node: {NodeId})",
|
||||
nextEdge.EdgeId, currentNode.NodeId);
|
||||
}
|
||||
|
||||
UpdateNavigationSpeed();
|
||||
|
||||
// VDA5050: Start EDGE actions for this edge
|
||||
// (Actions already added to ActionManager during HandleNewOrder)
|
||||
if (nextEdge.Actions.Length > 0)
|
||||
{
|
||||
Logger.LogInformation("Starting {Count} EDGE actions for edge {EdgeId}", nextEdge.Actions.Length, nextEdge.EdgeId);
|
||||
|
||||
// Separate NONE from blocking EDGE actions
|
||||
var noneActions = nextEdge.Actions.Where(a => a.BlockingType == BlockingType.NONE).ToArray();
|
||||
var blockingActions = nextEdge.Actions.Where(a => a.BlockingType == BlockingType.SOFT || a.BlockingType == BlockingType.HARD).ToArray();
|
||||
|
||||
// Start NONE actions immediately
|
||||
foreach (var action in noneActions)
|
||||
{
|
||||
ActionManager.StartOrderAction(action.ActionId);
|
||||
RunningEdgeActionIds.Add(action.ActionId);
|
||||
}
|
||||
|
||||
// Pause navigation and enqueue blocking actions
|
||||
if (blockingActions.Length > 0)
|
||||
{
|
||||
NavigationManager.Pause();
|
||||
IsWaitingPaused = true;
|
||||
|
||||
foreach (var action in blockingActions)
|
||||
{
|
||||
ActionWaitingRunning.Enqueue(action);
|
||||
RunningEdgeActionIds.Add(action.ActionId);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
// No next edge - clear current edge
|
||||
CurrentEdge = null;
|
||||
}
|
||||
|
||||
if (OrderActions.TryGetValue(currentNode.NodeId, out Action[]? actions) && actions is not null && actions.Length > 0)
|
||||
{
|
||||
// VDA5050 Compliance: Separate NONE actions from blocking actions
|
||||
var noneActions = actions.Where(a => a.BlockingType == BlockingType.NONE).ToArray();
|
||||
var blockingActions = actions.Where(a => a.BlockingType == BlockingType.SOFT || a.BlockingType == BlockingType.HARD).ToArray();
|
||||
|
||||
// Start NONE actions immediately - they can run during movement
|
||||
foreach (var action in noneActions)
|
||||
{
|
||||
ActionManager.StartOrderAction(action.ActionId);
|
||||
}
|
||||
|
||||
// Pause navigation only if there are SOFT/HARD actions
|
||||
if (blockingActions.Length > 0)
|
||||
{
|
||||
NavigationManager.Pause();
|
||||
IsWaitingPaused = true;
|
||||
|
||||
// Enqueue blocking actions for sequential execution
|
||||
foreach (var action in blockingActions)
|
||||
{
|
||||
ActionWaitingRunning.Enqueue(action);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ClearLastNode();
|
||||
}
|
||||
|
||||
UpdateNavigationSpeed();
|
||||
|
||||
// VDA5050: Improved blocking logic for parallel SOFT actions
|
||||
if (ActionHard is not null)
|
||||
{
|
||||
var robotAction = ActionManager[ActionHard.ActionId];
|
||||
if (robotAction is null) return;
|
||||
if (robotAction is not null && robotAction.IsCompleted) ActionHard = null;
|
||||
}
|
||||
else
|
||||
{
|
||||
if (!ActionWaitingRunning.IsEmpty)
|
||||
{
|
||||
IsActionRunning = !IsWaitingPaused || (IsWaitingPaused && NavigationManager.State == NavigationState.Paused);
|
||||
if (IsActionRunning)
|
||||
{
|
||||
// VDA5050: Check if there are running SOFT actions (both NODE and EDGE)
|
||||
var runningSoftActions = ActionManager.GetRunningActions()
|
||||
.Where(a => (a.ActionScope == ActionScope.NODE || a.ActionScope == ActionScope.EDGE) &&
|
||||
a.BlockingType == BlockingType.SOFT)
|
||||
.ToList();
|
||||
|
||||
// Try to start next action(s) from queue
|
||||
while (!ActionWaitingRunning.IsEmpty)
|
||||
{
|
||||
if (ActionWaitingRunning.TryPeek(out Action? action) && action is not null)
|
||||
{
|
||||
var robotAction = ActionManager[action.ActionId];
|
||||
if (robotAction is null)
|
||||
{
|
||||
// Action not found - dequeue and skip it
|
||||
ActionWaitingRunning.TryDequeue(out _);
|
||||
Logger.LogWarning($"Action {action.ActionId} (type: {action.ActionType}) not found in ActionManager - skipping action");
|
||||
continue;
|
||||
}
|
||||
|
||||
// VDA5050: Check if action can start based on blocking type
|
||||
if (action.BlockingType == BlockingType.HARD)
|
||||
{
|
||||
// HARD can only start if no actions are running
|
||||
if (runningSoftActions.Count > 0)
|
||||
{
|
||||
// Wait for SOFT actions to complete
|
||||
break;
|
||||
}
|
||||
// Start HARD action and set flag
|
||||
ActionWaitingRunning.TryDequeue(out _);
|
||||
ActionManager.StartOrderAction(action.ActionId);
|
||||
ActionHard = action;
|
||||
break; // Only one HARD action at a time
|
||||
}
|
||||
else if (action.BlockingType == BlockingType.SOFT)
|
||||
{
|
||||
// SOFT can start in parallel with other SOFT actions
|
||||
ActionWaitingRunning.TryDequeue(out _);
|
||||
ActionManager.StartOrderAction(action.ActionId);
|
||||
runningSoftActions.Add(robotAction);
|
||||
// Continue to potentially start more SOFT actions
|
||||
}
|
||||
else
|
||||
{
|
||||
// NONE should have been started already, but handle it anyway
|
||||
ActionWaitingRunning.TryDequeue(out _);
|
||||
ActionManager.StartOrderAction(action.ActionId);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
if (IsWaitingPaused)
|
||||
{
|
||||
IsWaitingPaused = false;
|
||||
NavigationManager.Resume();
|
||||
if (CurrentBaseNode is not null
|
||||
&& CurrentBaseNode.NodeId != Nodes[0].NodeId
|
||||
&& CurrentBaseNode.NodeId != Nodes[^1].NodeId
|
||||
&& Nodes.Length > 1)
|
||||
{
|
||||
NavigationManager.UpdateOrder(CurrentBaseNode.NodeId);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async void OrderHandler()
|
||||
{
|
||||
try
|
||||
{
|
||||
if (NewOrder is not null)
|
||||
{
|
||||
OrderMsg NewOrderHandler;
|
||||
lock (LockObject)
|
||||
{
|
||||
NewOrderHandler = NewOrder;
|
||||
NewOrder = null;
|
||||
}
|
||||
|
||||
if (NewOrderHandler.Nodes.Length == 0) throw new OrderException(RobotErrors.Error1002(NewOrderHandler.Nodes.Length));
|
||||
if (NewOrderHandler.Edges.Length != NewOrderHandler.Nodes.Length - 1) throw new OrderException(RobotErrors.Error1004(NewOrderHandler.Nodes.Length, NewOrderHandler.Edges.Length));
|
||||
|
||||
if (NodeStates.Length != 0 || EdgeStates.Length != 0) HandleUpdateOrder(NewOrderHandler);
|
||||
else
|
||||
{
|
||||
if (ActionManager.HasActionRunning) return;
|
||||
// Kiểm tra robot có nằm trên node đầu tien không
|
||||
Node startNode = NewOrderHandler.Nodes[0];
|
||||
var nodeDeviation = startNode.NodePosition?.AllowedDeviationXY == 0.0 ? NewOrderHandler.Nodes.Length == 1 ? 0.3 : 0.5 : startNode.NodePosition?.AllowedDeviationXY ?? 0.5;
|
||||
var distance = Localization.DistanceTo(startNode.NodePosition?.X ?? 0, startNode.NodePosition?.Y ?? 0);
|
||||
if (distance > nodeDeviation) throw new OrderException(RobotErrors.Error1016(startNode.NodeId, distance, nodeDeviation));
|
||||
|
||||
if (NewOrderHandler.Nodes.Length > 1)
|
||||
{
|
||||
Node endNode = NewOrderHandler.Nodes[^1];
|
||||
nodeDeviation = endNode.NodePosition?.AllowedDeviationXY == 0.0 ? 0.2 : endNode.NodePosition?.AllowedDeviationXY ?? 0.2;
|
||||
distance = Localization.DistanceTo(endNode.NodePosition?.X ?? 0, endNode.NodePosition?.Y ?? 0);
|
||||
if (distance < nodeDeviation) throw new OrderException(RobotErrors.Error1017(endNode.NodeId, distance, nodeDeviation));
|
||||
}
|
||||
await HandleNewOrder(NewOrderHandler);
|
||||
}
|
||||
}
|
||||
HandleOrder();
|
||||
}
|
||||
catch (RobotException orEx)
|
||||
{
|
||||
if (orEx.Error is not null)
|
||||
{
|
||||
ErrorManager.AddError(orEx.Error);
|
||||
Logger.LogWarning("Order processing error: {orEx.Error.ErrorDescription}", orEx.Error.ErrorDescription);
|
||||
}
|
||||
else Logger.LogWarning("Order processing error: {orEx.Message}", orEx.Message);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogWarning("Order processing error: {ex.Message}", ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
using RobotNet10.RobotApp.Interfaces;
|
||||
using RobotNet10.RobotApp.Shared.Enums;
|
||||
|
||||
namespace RobotNet10.RobotApp.Services.Robot;
|
||||
|
||||
public class RobotPhysicalConfig
|
||||
{
|
||||
public double WheelBase { get; set; }
|
||||
public double WheelRadius { get; set; }
|
||||
public double Width { get; set; }
|
||||
public double Length { get; set; }
|
||||
public double Height { get; set; }
|
||||
public NavigationType NavigationType { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
using RobotNet.VDA5050;
|
||||
using RobotNet.VDA5050.State;
|
||||
using RobotNet.VDA5050.Type;
|
||||
using RobotNet10.Common;
|
||||
using RobotNet10.RobotApp.Devices;
|
||||
using RobotNet10.RobotApp.Interfaces;
|
||||
using RobotNet10.RobotApp.Motion;
|
||||
using RobotNet10.RobotApp.Services.ConfigManager;
|
||||
using RobotNet10.RobotApp.Services.Navigation;
|
||||
using RobotNet10.RobotApp.Services.Robot.Connection;
|
||||
using RobotNet10.RobotApp.Services.State;
|
||||
|
||||
namespace RobotNet10.RobotApp.Services.Robot;
|
||||
|
||||
public class RobotStates(IConnectionConfig ConnectionConfig,
|
||||
IRobotConnectionsService RobotConnectionsService,
|
||||
RobotStateMachine StateManager,
|
||||
ILogger<RobotStates> Logger,
|
||||
IOrder OrderManager,
|
||||
IAction ActionManager,
|
||||
IPlcController PeripheralManager,
|
||||
IInfomation InfoManager,
|
||||
IError ErrorManager,
|
||||
ILocalization LocalizationManager,
|
||||
IDeviceProvider DeviceProvider,
|
||||
ILoad LoadManager,
|
||||
INavigation NavigationManager,
|
||||
IVelocityController VelocityController) : IState
|
||||
{
|
||||
private uint HeaderId = 0;
|
||||
|
||||
private WatchTimerAsync<RobotStates>? UpdateStateTimer;
|
||||
private const int UpdateStateInterval = 1000;
|
||||
|
||||
public async Task PubState()
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!RobotConnectionsService.IsConnected) return;
|
||||
await RobotConnectionsService.PublishStateAsync(GetStateMsg());
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
|
||||
private StateMsg GetStateMsg()
|
||||
{
|
||||
var vdaConfig = ConnectionConfig.GetVDA5050Config();
|
||||
var batteryDevice = DeviceProvider.GetDevice("battery-varta-001");
|
||||
RobotNet10.Shared.Sensor.BatteryState batteryState = new();
|
||||
if (batteryDevice is IBattery battery && battery.CurrentBatteryState.HasValue && battery.CurrentBatteryState is RobotNet10.Shared.Sensor.BatteryState state)
|
||||
{
|
||||
batteryState = state;
|
||||
}
|
||||
return new StateMsg
|
||||
{
|
||||
HeaderId = HeaderId++,
|
||||
Manufacturer = vdaConfig.Manufacturer,
|
||||
Version = vdaConfig.Version,
|
||||
SerialNumber = vdaConfig.SerialNumber,
|
||||
Maps = [],
|
||||
OrderId = OrderManager.OrderId,
|
||||
OrderUpdateId = OrderManager.OrderUpdateId,
|
||||
ZoneSetId = LocalizationManager.CurrentActiveMap,
|
||||
LastNodeId = OrderManager.LastNodeId,
|
||||
LastNodeSequenceId = OrderManager.LastNodeSequenceId,
|
||||
Driving = NavigationManager.Driving,
|
||||
Paused = OrderManager.IsPaused,
|
||||
NewBaseRequest = OrderManager.NewBaseRequest,
|
||||
DistanceSinceLastNode = OrderManager.DistanceSinceLastNode,
|
||||
OperatingMode = PeripheralManager.PeripheralMode.ToString(),
|
||||
NodeStates = OrderManager.NodeStates,
|
||||
EdgeStates = OrderManager.EdgeStates,
|
||||
ActionStates = ActionManager.ActionStates,
|
||||
Information = [General, .. InfoManager.InformationState],
|
||||
Errors = ErrorManager.ErrorsState,
|
||||
AgvPosition = new()
|
||||
{
|
||||
X = LocalizationManager.X,
|
||||
Y = LocalizationManager.Y,
|
||||
Theta = LocalizationManager.Theta,
|
||||
LocalizationScore = LocalizationManager.LocalizationScore,
|
||||
MapId = LocalizationManager.CurrentActiveMap,
|
||||
DeviationRange = LocalizationManager.DeviationRange,
|
||||
PositionInitialized = LocalizationManager.PositionInitialized,
|
||||
},
|
||||
BatteryState = new()
|
||||
{
|
||||
Charging = batteryState.Current > 0,
|
||||
BatteryHealth = 100,
|
||||
Reach = 0,
|
||||
BatteryVoltage = batteryState.Voltage is double.NaN ? 0 : batteryState.Voltage,
|
||||
BatteryCharge = batteryState.Percentage is double.NaN ? 0 : batteryState.Percentage,
|
||||
},
|
||||
Loads = LoadManager.Load,
|
||||
Velocity = new()
|
||||
{
|
||||
Vx = VelocityController.ActualVelocity.Linear,
|
||||
Vy = 0,
|
||||
Omega = VelocityController.ActualVelocity.Angular,
|
||||
},
|
||||
SafetyState = new()
|
||||
{
|
||||
FieldViolation = !PeripheralManager.LidarBackProtectField || !PeripheralManager.LidarFrontProtectField || PeripheralManager.LidarFrontTimProtectField,
|
||||
EStop = PeripheralManager.Emergency || PeripheralManager.Bumper ? EStop.AUTOACK : EStop.NONE,
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private Information General => new()
|
||||
{
|
||||
InfoType = InformationType.GENERAL.ToJsonString(),
|
||||
InfoDescription = "Thông tin chung của robot",
|
||||
InfoLevel = InfoLevel.INFO,
|
||||
InfoReferences =
|
||||
[
|
||||
new InfomationReference
|
||||
{
|
||||
ReferenceKey = InformationReferencesKey.STATE.ToJsonString(),
|
||||
ReferenceValue = StateManager.CurrentState.ToString(),
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
private async Task UpdateStateHandler()
|
||||
{
|
||||
await PubState();
|
||||
}
|
||||
|
||||
public void Start()
|
||||
{
|
||||
if (UpdateStateTimer is not null) Stop();
|
||||
|
||||
UpdateStateTimer = new(UpdateStateInterval, UpdateStateHandler, Logger);
|
||||
UpdateStateTimer.Start();
|
||||
}
|
||||
|
||||
public void Stop()
|
||||
{
|
||||
UpdateStateTimer?.Dispose();
|
||||
UpdateStateTimer = null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
using RobotNet.VDA5050.Visualization;
|
||||
using RobotNet10.Common;
|
||||
using RobotNet10.RobotApp.Interfaces;
|
||||
using RobotNet10.RobotApp.Services.ConfigManager;
|
||||
using RobotNet10.RobotApp.Services.Robot.Connection;
|
||||
|
||||
namespace RobotNet10.RobotApp.Services.Robot;
|
||||
|
||||
public class RobotVisualization(ILocalization Localization,
|
||||
INavigation Navigation,
|
||||
IConnectionConfig ConnectionConfig,
|
||||
IRobotConnectionsService RobotConnectionsService,
|
||||
ILogger<RobotVisualization> Logger)
|
||||
{
|
||||
private uint HeaderId;
|
||||
|
||||
private WatchThread<RobotVisualization>? UpdateTimer;
|
||||
private const int UpdateInterval = 100;
|
||||
private VisualizationMsg GetVisualizationMsg()
|
||||
{
|
||||
var vdaConfig = ConnectionConfig.GetVDA5050Config();
|
||||
return new VisualizationMsg()
|
||||
{
|
||||
HeaderId = HeaderId++,
|
||||
Manufacturer = vdaConfig.Manufacturer,
|
||||
Version = vdaConfig.Version,
|
||||
SerialNumber = vdaConfig.SerialNumber,
|
||||
AgvPosition = new AgvPosition()
|
||||
{
|
||||
X = Localization.X,
|
||||
Y = Localization.Y,
|
||||
Theta = Localization.Theta
|
||||
},
|
||||
Velocity = new Velocity()
|
||||
{
|
||||
Vx = Navigation.VelocityX,
|
||||
Vy = Navigation.VelocityY,
|
||||
Omega = Navigation.Omega
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private void UpdateHandler()
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!RobotConnectionsService.IsConnected) return;
|
||||
var publish = RobotConnectionsService.PublishVisualizationAsync(GetVisualizationMsg());
|
||||
publish.ConfigureAwait(false);
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
|
||||
public void Start()
|
||||
{
|
||||
if (UpdateTimer is not null) Stop();
|
||||
|
||||
UpdateTimer = new(UpdateInterval, UpdateHandler, Logger, ThreadPriority.Normal);
|
||||
UpdateTimer.Start();
|
||||
}
|
||||
|
||||
public void Stop()
|
||||
{
|
||||
UpdateTimer?.Dispose();
|
||||
UpdateTimer = null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
using RobotNet10.RobotApp.Models;
|
||||
using RobotNet10.RobotApp.Script.Shared;
|
||||
using RobotNet10.ScriptEngine.Helpers;
|
||||
using RobotNet10.ScriptEngine.Shared;
|
||||
using System.Collections.Immutable;
|
||||
|
||||
namespace RobotNet10.RobotApp.Services;
|
||||
|
||||
public class ScriptEngineResource : IScriptEngineResource
|
||||
{
|
||||
public Type AppGlobalType => RobotAppScriptEngineResource.GlobalType;
|
||||
|
||||
public ImmutableArray<string> UsingNamespaces => RobotAppScriptEngineResource.UsingNamespaces;
|
||||
|
||||
public ImmutableArray<string> Modules => RobotAppScriptEngineResource.Modules;
|
||||
|
||||
public ImmutableArray<string> DocModules => RobotAppScriptEngineResource.DocModules;
|
||||
|
||||
public IDictionary<string, object?> GetMissionGlobals(Guid id, CancellationToken cancellationToken)
|
||||
{
|
||||
var globals = new RobotAppScriptGlobals();
|
||||
return ScriptHelper.ConvertGlobalsToDictionary(globals, typeof(IRobotAppScriptGlobals));
|
||||
}
|
||||
|
||||
public IDictionary<string, object?> GetTaskGlobals()
|
||||
{
|
||||
var globals = new RobotAppScriptGlobals();
|
||||
return ScriptHelper.ConvertGlobalsToDictionary(globals, typeof(IRobotAppScriptGlobals));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,538 @@
|
||||
namespace RobotNet10.RobotApp.Services.Simulation.Algorithm;
|
||||
|
||||
public class FuzzyLogic
|
||||
{
|
||||
// ============================================================================
|
||||
// FUZZY LOGIC CONTROLLER - PHIÊN BẢN GỐC ĐÃ TỔ CHỨC LẠI
|
||||
// Dải đầu ra: 0.0 - 1.0 | Độ phân giải: 5 mức | Số luật: 25 (5x5)
|
||||
// ============================================================================
|
||||
|
||||
// ============================================================================
|
||||
// PHẦN 1: CÁC HÀM MEMBERSHIP (HÀM THUỘC)
|
||||
// ============================================================================
|
||||
|
||||
/// <summary>
|
||||
/// Hàm thuộc hình thang (Trapezoidal Membership Function)
|
||||
/// Dạng hình thang với 4 điểm: [left_base, left_top, right_top, right_base]
|
||||
///
|
||||
/// left_top ______ right_top
|
||||
/// / \
|
||||
/// / \
|
||||
/// _________/ \_________
|
||||
/// left_base right_base
|
||||
///
|
||||
/// </summary>
|
||||
/// <param name="inputValue">Giá trị đầu vào cần tính độ thuộc</param>
|
||||
/// <param name="left_base">Điểm bắt đầu hình thang (μ = 0)</param>
|
||||
/// <param name="left_top">Điểm bắt đầu vùng phẳng trên (μ = 1)</param>
|
||||
/// <param name="right_top">Điểm kết thúc vùng phẳng trên (μ = 1)</param>
|
||||
/// <param name="right_base">Điểm kết thúc hình thang (μ = 0)</param>
|
||||
/// <returns>Độ thuộc trong khoảng [0, 1]</returns>
|
||||
private static double Fuzzy_trapmf(double inputValue, double left_base, double left_top, double right_top, double right_base)
|
||||
{
|
||||
double membership = 0.0;
|
||||
|
||||
// Trường hợp 1: Nằm ngoài phía trái hình thang
|
||||
if (inputValue <= left_base)
|
||||
{
|
||||
membership = 0.0;
|
||||
}
|
||||
// Trường hợp 2: Nằm trên cạnh tăng dần (trái)
|
||||
else if (inputValue > left_base && inputValue < left_top)
|
||||
{
|
||||
if (left_top != left_base) // Tránh chia cho 0
|
||||
{
|
||||
membership = (inputValue - left_base) / (left_top - left_base);
|
||||
}
|
||||
}
|
||||
// Trường hợp 3: Nằm trên vùng phẳng (đỉnh)
|
||||
else if (inputValue >= left_top && inputValue <= right_top)
|
||||
{
|
||||
membership = 1.0;
|
||||
}
|
||||
// Trường hợp 4: Nằm trên cạnh giảm dần (phải)
|
||||
else if (inputValue > right_top && inputValue < right_base)
|
||||
{
|
||||
if (right_base != right_top) // Tránh chia cho 0
|
||||
{
|
||||
membership = (right_base - inputValue) / (right_base - right_top);
|
||||
}
|
||||
}
|
||||
// Trường hợp 5: Nằm ngoài phía phải hình thang
|
||||
else if (inputValue >= right_base)
|
||||
{
|
||||
membership = 0.0;
|
||||
}
|
||||
|
||||
return membership;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Hàm thuộc tam giác (Triangular Membership Function)
|
||||
/// Dạng tam giác với 3 điểm: [left, peak, right]
|
||||
///
|
||||
/// peak
|
||||
/// /\
|
||||
/// / \
|
||||
/// / \
|
||||
/// _____/ \_____
|
||||
/// left right
|
||||
///
|
||||
/// </summary>
|
||||
/// <param name="inputValue">Giá trị đầu vào cần tính độ thuộc</param>
|
||||
/// <param name="left">Điểm bắt đầu tam giác (μ = 0)</param>
|
||||
/// <param name="peak">Điểm đỉnh tam giác (μ = 1)</param>
|
||||
/// <param name="right">Điểm kết thúc tam giác (μ = 0)</param>
|
||||
/// <returns>Độ thuộc trong khoảng [0, 1]</returns>
|
||||
private static double Fuzzy_trimf(double inputValue, double left, double peak, double right)
|
||||
{
|
||||
double membership = 0.0;
|
||||
|
||||
// Trường hợp 1: Nằm ngoài phía trái tam giác
|
||||
if (inputValue <= left)
|
||||
{
|
||||
membership = 0.0;
|
||||
}
|
||||
// Trường hợp 2: Nằm trên cạnh tăng dần (trái)
|
||||
else if (inputValue > left && inputValue < peak)
|
||||
{
|
||||
if (peak != left) // Tránh chia cho 0
|
||||
{
|
||||
membership = (inputValue - left) / (peak - left);
|
||||
}
|
||||
}
|
||||
// Trường hợp 3: Đúng tại đỉnh tam giác
|
||||
else if (inputValue == peak)
|
||||
{
|
||||
membership = 1.0;
|
||||
}
|
||||
// Trường hợp 4: Nằm trên cạnh giảm dần (phải)
|
||||
else if (inputValue > peak && inputValue < right)
|
||||
{
|
||||
if (right != peak) // Tránh chia cho 0
|
||||
{
|
||||
membership = (right - inputValue) / (right - peak);
|
||||
}
|
||||
}
|
||||
// Trường hợp 5: Nằm ngoài phía phải tam giác
|
||||
else if (inputValue >= right)
|
||||
{
|
||||
membership = 0.0;
|
||||
}
|
||||
|
||||
return membership;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// PHẦN 2: FUZZIFICATION (MỜ HÓA ĐẦU VÀO)
|
||||
// ============================================================================
|
||||
|
||||
/// <summary>
|
||||
/// Mờ hóa tín hiệu PI thành 5 tập mờ
|
||||
/// </summary>
|
||||
/// <param name="piSignal">Tín hiệu đầu ra của bộ PI</param>
|
||||
/// <param name="membershipValues">Mảng lưu giá trị độ thuộc (vị trí 0-4)</param>
|
||||
private static void FuzzifyPISignal(double piSignal, double[] membershipValues)
|
||||
{
|
||||
// 1. NB (Negative Big): [-∞, -∞, -1.0, -0.5]
|
||||
membershipValues[0] = Fuzzy_trapmf(piSignal, -1.0E+10, -1.0E+10, -1.0, -0.5);
|
||||
|
||||
// 2. Z (Zero): [-0.5, 0.0, 0.5]
|
||||
membershipValues[1] = Fuzzy_trimf(piSignal, -0.5, 0.0, 0.5);
|
||||
|
||||
// 3. PB (Positive Big): [0.5, 1.0, +∞, +∞]
|
||||
membershipValues[2] = Fuzzy_trapmf(piSignal, 0.5, 1.0, 1.0E+10, 1.0E+10);
|
||||
|
||||
// 4. NM (Negative Medium): [-1.0, -0.5, 0.0]
|
||||
membershipValues[3] = Fuzzy_trimf(piSignal, -1.0, -0.5, 0.0);
|
||||
|
||||
// 5. PM (Positive Medium): [0.0, 0.5, 1.0]
|
||||
membershipValues[4] = Fuzzy_trimf(piSignal, 0.0, 0.5, 1.0);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Mờ hóa vận tốc V thành 11 tập mờ (dải 0.0 - 3.0)
|
||||
/// Độ phân giải: 0.3 m/s - Cân bằng tốt giữa độ chính xác và hiệu suất
|
||||
/// </summary>
|
||||
/// <param name="velocity">Vận tốc tuyến tính mong muốn (0.0 - 3.0 m/s)</param>
|
||||
/// <param name="membershipValues">Mảng lưu giá trị độ thuộc (vị trí 5-15)</param>
|
||||
private static void FuzzifyVelocity(double velocity, double[] membershipValues)
|
||||
{
|
||||
// 11 tập mờ phân bố đều từ 0.0 đến 3.0
|
||||
// Khoảng cách giữa các đỉnh: 3.0 / 10 = 0.3 m/s
|
||||
|
||||
// 1. VVS (Very Very Slow): [-∞, -∞, 0.0, 0.3]
|
||||
membershipValues[5] = Fuzzy_trapmf(velocity, -1.0E+9, -1.0E+9, 0.0, 0.3);
|
||||
|
||||
// 2. VS (Very Slow): [0.0, 0.3, 0.6]
|
||||
membershipValues[6] = Fuzzy_trimf(velocity, 0.0, 0.3, 0.6);
|
||||
|
||||
// 3. S- (Slow Low): [0.3, 0.6, 0.9]
|
||||
membershipValues[7] = Fuzzy_trimf(velocity, 0.3, 0.6, 0.9);
|
||||
|
||||
// 4. S (Slow): [0.6, 0.9, 1.2]
|
||||
membershipValues[8] = Fuzzy_trimf(velocity, 0.6, 0.9, 1.2);
|
||||
|
||||
// 5. S+ (Slow High): [0.9, 1.2, 1.5]
|
||||
membershipValues[9] = Fuzzy_trimf(velocity, 0.9, 1.2, 1.5);
|
||||
|
||||
// 6. M (Medium): [1.2, 1.5, 1.8]
|
||||
membershipValues[10] = Fuzzy_trimf(velocity, 1.2, 1.5, 1.8);
|
||||
|
||||
// 7. F- (Fast Low): [1.5, 1.8, 2.1]
|
||||
membershipValues[11] = Fuzzy_trimf(velocity, 1.5, 1.8, 2.1);
|
||||
|
||||
// 8. F (Fast): [1.8, 2.1, 2.4]
|
||||
membershipValues[12] = Fuzzy_trimf(velocity, 1.8, 2.1, 2.4);
|
||||
|
||||
// 9. F+ (Fast High): [2.1, 2.4, 2.7]
|
||||
membershipValues[13] = Fuzzy_trimf(velocity, 2.1, 2.4, 2.7);
|
||||
|
||||
// 10. VF (Very Fast): [2.4, 2.7, 3.0]
|
||||
membershipValues[14] = Fuzzy_trimf(velocity, 2.4, 2.7, 3.0);
|
||||
|
||||
// 11. VVF (Very Very Fast): [2.7, 3.0, +∞, +∞]
|
||||
membershipValues[15] = Fuzzy_trapmf(velocity, 2.7, 3.0, 1.0E+9, 1.0E+9);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// PHẦN 3: RULE EVALUATION (ĐÁNH GIÁ LUẬT MỜ)
|
||||
// ============================================================================
|
||||
|
||||
/// <summary>
|
||||
/// Đánh giá luật mờ cho một bộ điều khiển
|
||||
/// </summary>
|
||||
/// <param name="inputMembershipValues">Độ thuộc của các đầu vào (16 giá trị: 5 PI + 11 V)</param>
|
||||
/// <param name="ruleAntecedentIndices">Ma trận chỉ số tiền đề (numRules * 2 phần tử)</param>
|
||||
/// <param name="ruleConsequentIndices">Ma trận chỉ số hệ quả (numRules phần tử)</param>
|
||||
/// <param name="outputSingletons">Các giá trị singleton đầu ra</param>
|
||||
/// <param name="numRules">Số lượng luật</param>
|
||||
/// <returns>(weightedSum: tổng có trọng số, totalWeight: tổng trọng số)</returns>
|
||||
private static (double weightedSum, double totalWeight) EvaluateRules(
|
||||
double[] inputMembershipValues,
|
||||
byte[] ruleAntecedentIndices,
|
||||
byte[] ruleConsequentIndices,
|
||||
double[] outputSingletons,
|
||||
int numRules)
|
||||
{
|
||||
const int VELOCITY_OFFSET = 5; // Chỉ số bắt đầu của V trong inputMembershipValues
|
||||
|
||||
double weightedSum = 0.0;
|
||||
double totalWeight = 0.0;
|
||||
|
||||
for (int ruleIndex = 0; ruleIndex < numRules; ruleIndex++)
|
||||
{
|
||||
// Lấy chỉ số tập mờ cho PI (từ 1-5, cần trừ 1 để thành 0-4)
|
||||
int piMembershipIndex = ruleAntecedentIndices[ruleIndex] - 1;
|
||||
|
||||
// Lấy chỉ số tập mờ cho Velocity (từ 1-11, cộng offset)
|
||||
int velocityMembershipIndex = ruleAntecedentIndices[ruleIndex + numRules] + VELOCITY_OFFSET - 1;
|
||||
|
||||
// Tính độ kích hoạt của luật (AND operator = phép nhân)
|
||||
double ruleActivation = inputMembershipValues[piMembershipIndex]
|
||||
* inputMembershipValues[velocityMembershipIndex];
|
||||
|
||||
// Lấy giá trị singleton đầu ra tương ứng (từ 1-11, cần trừ 1)
|
||||
int outputIndex = ruleConsequentIndices[ruleIndex] - 1;
|
||||
double outputValue = outputSingletons[outputIndex];
|
||||
|
||||
// Tích lũy tổng trọng số và tổng có trọng số
|
||||
totalWeight += ruleActivation;
|
||||
weightedSum += outputValue * ruleActivation;
|
||||
}
|
||||
|
||||
return (weightedSum, totalWeight);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// PHẦN 4: DEFUZZIFICATION (GIẢI MỜ ĐẦU RA)
|
||||
// ============================================================================
|
||||
|
||||
/// <summary>
|
||||
/// Giải mờ bằng phương pháp trọng tâm (Weighted Average / Center of Gravity)
|
||||
/// Công thức: output = Σ(singleton_i × weight_i) / Σ(weight_i)
|
||||
/// </summary>
|
||||
/// <param name="weightedSum">Tổng đầu ra có trọng số</param>
|
||||
/// <param name="totalWeight">Tổng trọng số của tất cả các luật</param>
|
||||
/// <param name="defaultValue">Giá trị mặc định nếu totalWeight = 0</param>
|
||||
/// <returns>Giá trị đầu ra rõ (crisp output)</returns>
|
||||
private static double Defuzzify(double weightedSum, double totalWeight, double defaultValue = 0.5)
|
||||
{
|
||||
// Nếu không có luật nào được kích hoạt, trả về giá trị mặc định
|
||||
if (totalWeight == 0.0)
|
||||
{
|
||||
return defaultValue;
|
||||
}
|
||||
else
|
||||
{
|
||||
// Tính trọng tâm: output = weightedSum / totalWeight
|
||||
return weightedSum / totalWeight;
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// PHẦN 5: BẢNG LUẬT VÀ CẤU HÌNH
|
||||
// ============================================================================
|
||||
|
||||
/// <summary>
|
||||
/// Bảng chỉ số tiền đề luật cho bánh phải (Rule Antecedent Indices - Right Wheel)
|
||||
/// 110 phần tử: 55 cho PI + 55 cho Velocity
|
||||
/// Hệ thống: 5 tập mờ PI × 11 tập mờ V = 55 luật
|
||||
/// Giá trị từ 1-5 cho PI, 1-11 cho V
|
||||
/// </summary>
|
||||
private static readonly byte[] RULE_ANTECEDENT_INDICES_RIGHT =
|
||||
[
|
||||
// 55 phần tử đầu: Chỉ số tập mờ của PI Signal (1-5)
|
||||
// Mỗi tập PI lặp lại 11 lần (cho 11 mức vận tốc)
|
||||
1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, // Luật 1-11: PI = NB (tập 1)
|
||||
2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, // Luật 12-22: PI = Z (tập 2)
|
||||
3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, // Luật 23-33: PI = PB (tập 3)
|
||||
4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, // Luật 34-44: PI = NM (tập 4)
|
||||
5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, // Luật 45-55: PI = PM (tập 5)
|
||||
|
||||
// 55 phần tử sau: Chỉ số tập mờ của Velocity (1-11)
|
||||
// Lặp lại theo pattern: VVS, VS, S-, S, S+, M, F-, F, F+, VF, VVF
|
||||
1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, // Luật 1-11: V = VVS đến VVF
|
||||
1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, // Luật 12-22: V = VVS đến VVF
|
||||
1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, // Luật 23-33: V = VVS đến VVF
|
||||
1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, // Luật 34-44: V = VVS đến VVF
|
||||
1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11 // Luật 45-55: V = VVS đến VVF
|
||||
];
|
||||
|
||||
/// <summary>
|
||||
/// Bảng chỉ số hệ quả cho bánh phải (Rule Consequent Indices - Right Wheel)
|
||||
/// 55 phần tử tương ứng với 55 luật
|
||||
/// Giá trị từ 1-11 tương ứng với 11 mức tốc độ đầu ra (0.0 - 3.0)
|
||||
///
|
||||
/// Logic bánh phải:
|
||||
/// - PI âm (NB, NM): Bánh phải chậm hơn → Robot rẽ trái
|
||||
/// - PI = 0 (Z): Bánh phải theo vận tốc V → Robot đi thẳng
|
||||
/// - PI dương (PB, PM): Bánh phải nhanh hơn → Robot rẽ phải
|
||||
/// </summary>
|
||||
private static readonly byte[] RULE_CONSEQUENT_INDICES_RIGHT =
|
||||
[
|
||||
// PI = NB (Negative Big) - Bánh phải RẤT CHẬM (rẽ trái mạnh)
|
||||
// V: VVS VS S- S S+ M F- F F+ VF VVF
|
||||
1, 1, 1, 2, 2, 3, 3, 4, 5, 6, 7, // Luật 1-11
|
||||
|
||||
// PI = Z (Zero) - Bánh phải THEO VẬN TỐC (đi thẳng)
|
||||
// V: VVS VS S- S S+ M F- F F+ VF VVF
|
||||
2, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, // Luật 12-22
|
||||
|
||||
// PI = PB (Positive Big) - Bánh phải RẤT NHANH (rẽ phải mạnh)
|
||||
// V: VVS VS S- S S+ M F- F F+ VF VVF
|
||||
4, 5, 6, 7, 8, 9, 10, 10, 11, 11, 11, // Luật 23-33
|
||||
|
||||
// PI = NM (Negative Medium) - Bánh phải HƠI CHẬM (rẽ trái nhẹ)
|
||||
// V: VVS VS S- S S+ M F- F F+ VF VVF
|
||||
1, 2, 2, 3, 4, 5, 6, 7, 8, 9, 10, // Luật 34-44
|
||||
|
||||
// PI = PM (Positive Medium) - Bánh phải HƠI NHANH (rẽ phải nhẹ)
|
||||
// V: VVS VS S- S S+ M F- F F+ VF VVF
|
||||
2, 3, 4, 5, 6, 7, 8, 8, 9, 10, 11 // Luật 45-55
|
||||
];
|
||||
|
||||
/// <summary>
|
||||
/// Bảng chỉ số tiền đề luật cho bánh trái (Rule Antecedent Indices - Left Wheel)
|
||||
/// 110 phần tử: 55 cho PI + 55 cho Velocity
|
||||
/// </summary>
|
||||
private static readonly byte[] RULE_ANTECEDENT_INDICES_LEFT =
|
||||
[
|
||||
// 55 phần tử đầu: Chỉ số tập mờ của PI Signal (1-5)
|
||||
1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, // Luật 1-11: PI = NB (tập 1)
|
||||
2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, // Luật 12-22: PI = Z (tập 2)
|
||||
3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, // Luật 23-33: PI = PB (tập 3)
|
||||
4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, // Luật 34-44: PI = NM (tập 4)
|
||||
5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, // Luật 45-55: PI = PM (tập 5)
|
||||
|
||||
// 55 phần tử sau: Chỉ số tập mờ của Velocity (1-11)
|
||||
1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, // Luật 1-11
|
||||
1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, // Luật 12-22
|
||||
1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, // Luật 23-33
|
||||
1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, // Luật 34-44
|
||||
1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11 // Luật 45-55
|
||||
];
|
||||
|
||||
/// <summary>
|
||||
/// Bảng chỉ số hệ quả cho bánh trái (Rule Consequent Indices - Left Wheel)
|
||||
/// 55 phần tử tương ứng với 55 luật
|
||||
///
|
||||
/// Logic bánh trái: NGƯỢC LẠI với bánh phải
|
||||
/// - PI âm (NB, NM): Bánh trái nhanh hơn → Robot rẽ trái
|
||||
/// - PI = 0 (Z): Bánh trái theo vận tốc V → Robot đi thẳng
|
||||
/// - PI dương (PB, PM): Bánh trái chậm hơn → Robot rẽ phải
|
||||
/// </summary>
|
||||
private static readonly byte[] RULE_CONSEQUENT_INDICES_LEFT =
|
||||
[
|
||||
// PI = NB (Negative Big) - Bánh trái RẤT NHANH (rẽ trái mạnh)
|
||||
// V: VVS VS S- S S+ M F- F F+ VF VVF
|
||||
4, 5, 6, 7, 8, 9, 10, 10, 11, 11, 11, // Luật 1-11
|
||||
|
||||
// PI = Z (Zero) - Bánh trái THEO VẬN TỐC (đi thẳng)
|
||||
// V: VVS VS S- S S+ M F- F F+ VF VVF
|
||||
2, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, // Luật 12-22
|
||||
|
||||
// PI = PB (Positive Big) - Bánh trái RẤT CHẬM (rẽ phải mạnh)
|
||||
// V: VVS VS S- S S+ M F- F F+ VF VVF
|
||||
1, 1, 1, 2, 2, 3, 3, 4, 5, 6, 7, // Luật 23-33
|
||||
|
||||
// PI = NM (Negative Medium) - Bánh trái HƠI NHANH (rẽ trái nhẹ)
|
||||
// V: VVS VS S- S S+ M F- F F+ VF VVF
|
||||
3, 4, 5, 6, 7, 8, 8, 9, 10, 11, 11, // Luật 34-44
|
||||
|
||||
// PI = PM (Positive Medium) - Bánh trái HƠI CHẬM (rẽ phải nhẹ)
|
||||
// V: VVS VS S- S S+ M F- F F+ VF VVF
|
||||
1, 2, 2, 3, 4, 4, 5, 6, 7, 8, 9, // Luật 45-55
|
||||
];
|
||||
|
||||
/// <summary>
|
||||
/// Các mức đầu ra singleton (Output Singletons)
|
||||
/// 11 mức tốc độ từ 0.0 đến 3.0 m/s
|
||||
/// Độ phân giải: 3.0 / 10 = 0.3 m/s
|
||||
/// </summary>
|
||||
private static readonly double[] OUTPUT_SINGLETON_LEVELS =
|
||||
[
|
||||
0.0, // Mức 1: Dừng hoàn toàn (0%)
|
||||
0.3, // Mức 2: Rất chậm (10%)
|
||||
0.6, // Mức 3: Chậm (20%)
|
||||
0.9, // Mức 4: Hơi chậm (30%)
|
||||
1.2, // Mức 5: Chậm vừa (40%)
|
||||
1.5, // Mức 6: Trung bình (50%) - Điểm chuẩn
|
||||
1.8, // Mức 7: Hơi nhanh (60%)
|
||||
2.1, // Mức 8: Nhanh (70%)
|
||||
2.4, // Mức 9: Nhanh vừa (80%)
|
||||
2.7, // Mức 10: Rất nhanh (90%)
|
||||
3.0 // Mức 11: Tối đa (100%)
|
||||
];
|
||||
|
||||
// ============================================================================
|
||||
// PHẦN 6: HÀM CHÍNH - BỘ ĐIỀU KHIỂN PI + FUZZY
|
||||
// ============================================================================
|
||||
|
||||
// Biến trạng thái bộ tích phân
|
||||
private static double integratorState = 0.0;
|
||||
|
||||
// Hệ số bộ điều khiển PI
|
||||
private static double proportionalGain = 1.0; // Hệ số tỷ lệ (Kp)
|
||||
private static double integralGain = 0.1; // Hệ số tích phân (Ki)
|
||||
|
||||
/// <summary>
|
||||
/// Hàm điều khiển chính - Tính toán tốc độ bánh trái và phải
|
||||
/// Sử dụng bộ điều khiển PI kết hợp với logic mờ
|
||||
///
|
||||
/// CẤU HÌNH PHIÊN BẢN 3.0 - 11 TẬP MỜ:
|
||||
/// - PI Signal: 5 tập mờ (NB, Z, PB, NM, PM)
|
||||
/// - Velocity: 11 tập mờ (VVS, VS, S-, S, S+, M, F-, F, F+, VF, VVF)
|
||||
/// - Tổng số luật: 5 × 11 = 55 luật cho mỗi bánh
|
||||
/// - Dải đầu ra: 0.0 - 3.0 m/s
|
||||
/// - Độ phân giải: 0.3 m/s (tối ưu - cân bằng giữa độ mịn và hiệu suất)
|
||||
/// </summary>
|
||||
/// <param name="desiredVelocity">Vận tốc tuyến tính mong muốn (0.0 - 3.0 m/s)</param>
|
||||
/// <param name="desiredAngularVelocity">Vận tốc góc mong muốn (rad/s)</param>
|
||||
/// <param name="samplingTime">Chu kỳ lấy mẫu (giây, khuyến nghị 0.01s)</param>
|
||||
/// <returns>(leftWheelSpeed, rightWheelSpeed): Tốc độ bánh trái và phải trong [0.0, 3.0]</returns>
|
||||
public (double leftWheelSpeed, double rightWheelSpeed) Fuzzy_step(
|
||||
double desiredVelocity,
|
||||
double desiredAngularVelocity,
|
||||
double samplingTime)
|
||||
{
|
||||
const int NUM_INPUT_MEMBERSHIPS = 16; // 5 cho PI + 11 cho Velocity
|
||||
const int NUM_OUTPUT_LEVELS = 11; // 11 mức đầu ra (0.0 - 3.0)
|
||||
const int NUM_RULES = 55; // 5 × 11 = 55 luật
|
||||
|
||||
// Khởi tạo mảng lưu độ thuộc đầu vào
|
||||
double[] inputMembershipValues = new double[NUM_INPUT_MEMBERSHIPS];
|
||||
|
||||
// Khởi tạo mảng lưu các mức đầu ra
|
||||
double[] outputLevels = new double[NUM_OUTPUT_LEVELS];
|
||||
Array.Copy(OUTPUT_SINGLETON_LEVELS, outputLevels, NUM_OUTPUT_LEVELS);
|
||||
|
||||
// ========== BƯỚC 1: BỘ ĐIỀU KHIỂN PI ==========
|
||||
// Cập nhật trạng thái tích phân: I(t) = I(t-1) + Ki * error * dt
|
||||
integratorState += integralGain * desiredAngularVelocity * samplingTime;
|
||||
|
||||
// Chống bão hòa tích phân (Anti-windup)
|
||||
integratorState = Math.Clamp(integratorState, -1.5, 1.5);
|
||||
|
||||
// Tính tín hiệu điều khiển PI: u(t) = Kp * error + I(t)
|
||||
double piControlSignal = proportionalGain * desiredAngularVelocity + integratorState;
|
||||
|
||||
// ========== BƯỚC 2: MỜ HÓA ĐẦU VÀO ==========
|
||||
FuzzifyPISignal(piControlSignal, inputMembershipValues);
|
||||
FuzzifyVelocity(desiredVelocity, inputMembershipValues);
|
||||
|
||||
// ========== BƯỚC 3: TÍNH TOÁN BÁNH PHẢI ==========
|
||||
var (weightedSum_Right, totalWeight_Right) = EvaluateRules(
|
||||
inputMembershipValues,
|
||||
RULE_ANTECEDENT_INDICES_RIGHT,
|
||||
RULE_CONSEQUENT_INDICES_RIGHT,
|
||||
outputLevels,
|
||||
NUM_RULES
|
||||
);
|
||||
|
||||
double rightWheelSpeed = Defuzzify(weightedSum_Right, totalWeight_Right, defaultValue: 1.5);
|
||||
|
||||
// ========== BƯỚC 4: TÍNH TOÁN BÁNH TRÁI ==========
|
||||
var (weightedSum_Left, totalWeight_Left) = EvaluateRules(
|
||||
inputMembershipValues,
|
||||
RULE_ANTECEDENT_INDICES_LEFT,
|
||||
RULE_CONSEQUENT_INDICES_LEFT,
|
||||
outputLevels,
|
||||
NUM_RULES
|
||||
);
|
||||
|
||||
double leftWheelSpeed = Defuzzify(weightedSum_Left, totalWeight_Left, defaultValue: 1.5);
|
||||
|
||||
// ========== BƯỚC 5: GIỚI HẠN AN TOÀN ==========
|
||||
// Đảm bảo tốc độ không vượt quá giới hạn
|
||||
leftWheelSpeed = Math.Clamp(leftWheelSpeed, 0.0, 3.0);
|
||||
rightWheelSpeed = Math.Clamp(rightWheelSpeed, 0.0, 3.0);
|
||||
|
||||
// ========== BƯỚC 6: TRẢ VỀ KẾT QUẢ ==========
|
||||
return (leftWheelSpeed, rightWheelSpeed);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// PHẦN 7: HÀM HỖ TRỢ
|
||||
// ============================================================================
|
||||
|
||||
/// <summary>
|
||||
/// Reset trạng thái bộ tích phân về 0
|
||||
/// Nên gọi khi bắt đầu chu kỳ điều khiển mới hoặc khi cần reset hệ thống
|
||||
/// </summary>
|
||||
public void ResetIntegrator()
|
||||
{
|
||||
integratorState = 0.0;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Thiết lập hệ số cho bộ điều khiển PI
|
||||
/// </summary>
|
||||
/// <param name="kp">Hệ số tỷ lệ (Proportional Gain) - Phản ứng với sai số hiện tại</param>
|
||||
/// <param name="ki">Hệ số tích phân (Integral Gain) - Loại bỏ sai số tích lũy</param>
|
||||
public FuzzyLogic WithPIGains(double kp, double ki)
|
||||
{
|
||||
proportionalGain = kp;
|
||||
integralGain = ki;
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Lấy trạng thái hiện tại của bộ tích phân
|
||||
/// Hữu ích cho việc debug và giám sát hệ thống
|
||||
/// </summary>
|
||||
/// <returns>Giá trị tích phân hiện tại</returns>
|
||||
public double GetIntegratorState()
|
||||
{
|
||||
return integratorState;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Lấy hệ số PI hiện tại
|
||||
/// </summary>
|
||||
/// <returns>(Kp, Ki): Hệ số tỷ lệ và tích phân</returns>
|
||||
public (double Kp, double Ki) GetPIGains()
|
||||
{
|
||||
return (proportionalGain, integralGain);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
namespace RobotNet10.RobotApp.Services.Simulation.Algorithm;
|
||||
|
||||
public class PID
|
||||
{
|
||||
private double Kp = 0.3;
|
||||
private double Ki = 0.0001;
|
||||
private double Kd = 0.01;
|
||||
|
||||
private double _prevError;
|
||||
private double _integral;
|
||||
|
||||
public PID WithKp(double kp)
|
||||
{
|
||||
Kp = kp;
|
||||
return this;
|
||||
}
|
||||
|
||||
public PID WithKi(double ki)
|
||||
{
|
||||
Ki = ki;
|
||||
return this;
|
||||
}
|
||||
|
||||
public PID WithKd(double kd)
|
||||
{
|
||||
Kd = kd;
|
||||
return this;
|
||||
}
|
||||
|
||||
public double PID_step(double error, double max, double min, double timeSample)
|
||||
{
|
||||
double integralStep = 0.5 * (error + _prevError) * timeSample;
|
||||
_integral += integralStep;
|
||||
|
||||
double derivative = (error - _prevError) / timeSample;
|
||||
_prevError = error;
|
||||
|
||||
double Out = Kp * error
|
||||
+ Ki * _integral
|
||||
+ Kd * derivative;
|
||||
|
||||
// Anti-windup
|
||||
double clamped = Math.Clamp(Out, min, max);
|
||||
if (clamped != Out)
|
||||
_integral -= integralStep;
|
||||
|
||||
return clamped;
|
||||
}
|
||||
|
||||
public void Reset()
|
||||
{
|
||||
_prevError = 0;
|
||||
_integral = 0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,380 @@
|
||||
using RobotNet.VDA5050.Order;
|
||||
using RobotNet10.Common;
|
||||
using RobotNet10.Common.Models;
|
||||
using RobotNet10.RobotApp.Services.Exceptions;
|
||||
using RobotNet10.RobotApp.Services.Robot;
|
||||
using RobotNet10.RobotApp.Services.Robot.Helper;
|
||||
using RobotNet10.RobotApp.Services.Robot.Models;
|
||||
using RobotNet10.RobotApp.Shared.Enums;
|
||||
|
||||
namespace RobotNet10.RobotApp.Services.Simulation.Algorithm;
|
||||
|
||||
public class PurePursuit
|
||||
{
|
||||
private double MaxAngularVelocity = 1.5;
|
||||
private double LookaheadDistance = 0.5;
|
||||
|
||||
private readonly double ResolutionSplit = 0.1;
|
||||
|
||||
private KDTree? KDTreeOrder;
|
||||
private KDTree? KDTreeWay;
|
||||
private OrderNode? LastNode;
|
||||
private Dictionary<string, (int start, int end)>? _segmentCache;
|
||||
|
||||
public OrderNode? LastOrderNode = null;
|
||||
private int LastNavNodeIndex = 0;
|
||||
public int OnNodeIndex = 0;
|
||||
public NavigationNode? Goal;
|
||||
public List<NavigationNode> Waypoints_Value = [];
|
||||
|
||||
public OrderNode[] OrderNodes = [];
|
||||
public OrderEdge[] OrderEdges = [];
|
||||
|
||||
public PurePursuit WithLookheadDistance(double distance)
|
||||
{
|
||||
LookaheadDistance = distance;
|
||||
return this;
|
||||
}
|
||||
|
||||
public PurePursuit WithMaxAngularVelocity(double vel)
|
||||
{
|
||||
MaxAngularVelocity = vel;
|
||||
return this;
|
||||
}
|
||||
|
||||
public PurePursuit WithPath(Node[] nodes, Edge[] edges, double currentTheta)
|
||||
{
|
||||
if (nodes.Length < 2) throw new SimulationException(RobotErrors.Error1002(nodes.Length));
|
||||
if (edges.Length < 1) throw new SimulationException();
|
||||
if (edges.Length != nodes.Length - 1) throw new SimulationException(RobotErrors.Error1004(nodes.Length, edges.Length));
|
||||
(OrderNodes, OrderEdges) = OrderConverter.Validate(nodes, edges, currentTheta);
|
||||
Waypoints_Value = [.. PathSplit(OrderNodes, OrderEdges)];
|
||||
BuildSegmentCache();
|
||||
KDTreeOrder = new KDTree(OrderNodes.Select(n => new KDTreeData(n.NodeId, n.X, n.Y)));
|
||||
KDTreeWay = new KDTree(Waypoints_Value.Select(n => new KDTreeData(n.Id.ToString(), n.X, n.Y)));
|
||||
return this;
|
||||
}
|
||||
|
||||
public void UpdateGoal(string goalId)
|
||||
{
|
||||
var goal = Waypoints_Value.FirstOrDefault(n => n.NodeId == goalId);
|
||||
if (goal is not null) Goal = goal;
|
||||
}
|
||||
|
||||
private NavigationNode[] PathSplit(OrderNode[] nodes, OrderEdge[] edges)
|
||||
{
|
||||
List<NavigationNode> navigationNode = [new()
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
NodeId = nodes[0].NodeId,
|
||||
X = nodes[0].X,
|
||||
Y = nodes[0].Y,
|
||||
Theta = nodes[0].Theta,
|
||||
Direction = edges[0].Direction,
|
||||
}];
|
||||
foreach (var edge in edges)
|
||||
{
|
||||
var startNode = nodes.FirstOrDefault(n => n.NodeId == edge.StartNodeId);
|
||||
var endNode = nodes.FirstOrDefault(n => n.NodeId == edge.EndNodeId);
|
||||
if (startNode is null) throw new PathPlannerException(RobotErrors.Error1008(edge.EdgeId, edge.StartNodeId));
|
||||
if (endNode is null) throw new PathPlannerException(RobotErrors.Error1009(edge.EdgeId, edge.EndNodeId));
|
||||
|
||||
var spaceEdge = new SpaceEdge()
|
||||
{
|
||||
StartX = startNode.X,
|
||||
StartY = startNode.Y,
|
||||
EndX = endNode.X,
|
||||
EndY = endNode.Y,
|
||||
ControlPoint1X = edge.ControlPoint1X ?? 0,
|
||||
ControlPoint1Y = edge.ControlPoint1Y ?? 0,
|
||||
ControlPoint2X = edge.ControlPoint2X ?? 0,
|
||||
ControlPoint2Y = edge.ControlPoint2Y ?? 0,
|
||||
Degree = edge.Degree,
|
||||
};
|
||||
|
||||
double length = SpaceCompute.GetEdgeLength(spaceEdge, ResolutionSplit);
|
||||
if (length <= 0) continue;
|
||||
double step = ResolutionSplit / length;
|
||||
|
||||
for (double t = step; t <= 1 - step; t += step)
|
||||
{
|
||||
(double x, double y) = SpaceCompute.BezierPoint(t, spaceEdge);
|
||||
navigationNode.Add(new()
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
NodeId = string.Empty,
|
||||
X = x,
|
||||
Y = y,
|
||||
Theta = null,
|
||||
Direction = edge.Direction,
|
||||
});
|
||||
}
|
||||
navigationNode.Add(new()
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
NodeId = endNode.NodeId,
|
||||
X = endNode.X,
|
||||
Y = endNode.Y,
|
||||
Theta = endNode.Theta,
|
||||
Direction = edge.Direction,
|
||||
});
|
||||
}
|
||||
return [.. navigationNode];
|
||||
}
|
||||
|
||||
private void BuildSegmentCache()
|
||||
{
|
||||
_segmentCache = [];
|
||||
|
||||
for (int i = 0; i < OrderNodes.Length - 1; i++)
|
||||
{
|
||||
var currentNodeId = OrderNodes[i].NodeId;
|
||||
|
||||
// Xác định phạm vi: từ (i-1) đến (i+1)
|
||||
int rangeStart = Math.Max(0, i - 1);
|
||||
int rangeEnd = Math.Min(OrderNodes.Length - 1, i + 1);
|
||||
|
||||
var startNodeId = OrderNodes[rangeStart].NodeId;
|
||||
var endNodeId = OrderNodes[rangeEnd].NodeId;
|
||||
|
||||
// Tìm indices trong Waypoints_Value
|
||||
int waypointStartIdx = Waypoints_Value.FindIndex(n => n.NodeId == startNodeId);
|
||||
int waypointEndIdx = Waypoints_Value.FindIndex(n => n.NodeId == endNodeId);
|
||||
|
||||
// Xử lý trường hợp không tìm thấy
|
||||
if (waypointStartIdx == -1) waypointStartIdx = 0;
|
||||
if (waypointEndIdx == -1) waypointEndIdx = Waypoints_Value.Count - 1;
|
||||
|
||||
// Cache theo NodeId của OrderNode
|
||||
_segmentCache[currentNodeId] = (waypointStartIdx, waypointEndIdx);
|
||||
}
|
||||
}
|
||||
|
||||
private (OrderNode node, int index)? GetOnNodeWithKDTree(double x, double y)
|
||||
{
|
||||
KDTreeOrder ??= new KDTree(OrderNodes.Select(n => new KDTreeData(n.NodeId, n.X, n.Y)));
|
||||
LastNode ??= OrderNodes[0];
|
||||
var dx = LastNode.X - x;
|
||||
var dy = LastNode.Y - y;
|
||||
var minDistance = Math.Sqrt(dx * dx + dy * dy);
|
||||
var closesFindedNode = KDTreeOrder.FindNearest(x, y, minDistance);
|
||||
if (closesFindedNode == null) return null;
|
||||
|
||||
var closesNodeIndex = Array.FindIndex(OrderNodes, n => n.NodeId == closesFindedNode?.Id);
|
||||
if (closesNodeIndex == -1) return null;
|
||||
|
||||
if (OrderNodes[closesNodeIndex].NodeId != LastNode.NodeId)
|
||||
{
|
||||
var skipIndex = closesNodeIndex == 0 ? 0 : closesNodeIndex - 1;
|
||||
var newNodes = OrderNodes.Skip(skipIndex);
|
||||
KDTreeOrder = new KDTree(newNodes.Select(n => new KDTreeData(n.NodeId, n.X, n.Y)));
|
||||
|
||||
LastNode = OrderNodes[closesNodeIndex];
|
||||
}
|
||||
return (OrderNodes[closesNodeIndex], closesNodeIndex);
|
||||
}
|
||||
|
||||
private (OrderNode node, int index) GetOnNode(double x, double y)
|
||||
{
|
||||
LastNode ??= OrderNodes[0];
|
||||
var lastIndex = Array.FindIndex(OrderNodes, n => n.NodeId == LastNode?.NodeId);
|
||||
lastIndex = Math.Max(0, lastIndex);
|
||||
|
||||
var dx = LastNode.X - x;
|
||||
var dy = LastNode.Y - y;
|
||||
var minDistance = Math.Sqrt(dx * dx + dy * dy);
|
||||
|
||||
OrderNode onNode = LastNode;
|
||||
int index = 0;
|
||||
for (int i = lastIndex; i < OrderNodes.Length; i++)
|
||||
{
|
||||
var node = OrderNodes[i]; // FIX: Dùng đúng array
|
||||
dx = x - node.X;
|
||||
dy = y - node.Y;
|
||||
var distance = dx * dx + dy * dy;
|
||||
if (distance < minDistance)
|
||||
{
|
||||
onNode = OrderNodes[i];
|
||||
minDistance = distance;
|
||||
index = i;
|
||||
}
|
||||
}
|
||||
return (onNode, index);
|
||||
}
|
||||
|
||||
public (NavigationNode node, int index) GetOnNavNode(double x, double y)
|
||||
{
|
||||
double minDistance = double.MaxValue;
|
||||
NavigationNode onNode = Waypoints_Value[0];
|
||||
int index = 0;
|
||||
for (int i = 1; i < Waypoints_Value.Count; i++)
|
||||
{
|
||||
var distance = Math.Sqrt(Math.Pow(x - Waypoints_Value[i].X, 2) + Math.Pow(y - Waypoints_Value[i].Y, 2));
|
||||
if (distance < minDistance)
|
||||
{
|
||||
onNode = Waypoints_Value[i];
|
||||
minDistance = distance;
|
||||
index = i;
|
||||
}
|
||||
}
|
||||
return (onNode, index);
|
||||
}
|
||||
|
||||
private (NavigationNode? node, int index) OnNodeLinear(double x, double y)
|
||||
{
|
||||
var (node, index) = GetOnNodeWithKDTree(x, y) ?? GetOnNode(x, y);
|
||||
|
||||
int waypointStartIdx = 0;
|
||||
int waypointEndIdx = Waypoints_Value.Count - 1;
|
||||
if (_segmentCache is null)
|
||||
{
|
||||
var startIndex = Math.Max(0, index - 1);
|
||||
var endIndex = Math.Min(OrderNodes.Length - 1, index + 1);
|
||||
var navStartNodeIndex = Waypoints_Value.FindIndex(n => n.NodeId == OrderNodes[startIndex].NodeId);
|
||||
waypointStartIdx = navStartNodeIndex == -1 ? 0 : navStartNodeIndex;
|
||||
var navEndNodeIndex = Waypoints_Value.FindIndex(n => n.NodeId == OrderNodes[endIndex].NodeId);
|
||||
waypointEndIdx = navEndNodeIndex == -1 ? Waypoints_Value.Count - 1 : navEndNodeIndex;
|
||||
}
|
||||
else if (_segmentCache.TryGetValue(node.NodeId, out var range)) (waypointStartIdx, waypointEndIdx) = range;
|
||||
|
||||
waypointStartIdx = Math.Min(waypointStartIdx, OnNodeIndex);
|
||||
|
||||
var dx = OrderNodes[index].X - x;
|
||||
var dy = OrderNodes[index].Y - y;
|
||||
var minDistance = Math.Sqrt(dx * dx + dy * dy);
|
||||
|
||||
NavigationNode? bestNode = null;
|
||||
int bestIndex = -1;
|
||||
|
||||
for (int i = waypointStartIdx; i <= waypointEndIdx; i++)
|
||||
{
|
||||
var orderNodeId = Waypoints_Value[i].NodeId;
|
||||
|
||||
// Tìm NavigationNode tương ứng
|
||||
var navIndex = Waypoints_Value.FindIndex(n => n.NodeId == orderNodeId);
|
||||
if (navIndex == -1) continue;
|
||||
|
||||
var navNode = Waypoints_Value[navIndex];
|
||||
dx = x - navNode.X;
|
||||
dy = y - navNode.Y;
|
||||
var distanceSq = dx * dx + dy * dy;
|
||||
|
||||
if (distanceSq < minDistance)
|
||||
{
|
||||
minDistance = distanceSq;
|
||||
bestNode = navNode;
|
||||
bestIndex = navIndex;
|
||||
}
|
||||
}
|
||||
|
||||
return (bestNode, bestIndex);
|
||||
}
|
||||
|
||||
private (NavigationNode? node, int index) OnNodeWithKDTree(double x, double y)
|
||||
{
|
||||
KDTreeWay ??= new KDTree(Waypoints_Value.Select(n => new KDTreeData(n.Id.ToString(), n.X, n.Y)));
|
||||
var kdtreeNode = KDTreeWay.FindNearest(x, y, double.MaxValue);
|
||||
if(kdtreeNode is not null && Guid.TryParse(kdtreeNode.Id, out Guid kdtreeNodeId))
|
||||
{
|
||||
var nodeIndex = Waypoints_Value.FindIndex(n => n.Id == kdtreeNodeId);
|
||||
if(nodeIndex != -1) return (Waypoints_Value[nodeIndex], nodeIndex);
|
||||
}
|
||||
return (null, -1);
|
||||
}
|
||||
|
||||
private (NavigationNode? node, int index) OnNode(double x, double y)
|
||||
{
|
||||
var (node, index) = OnNodeWithKDTree(x, y);
|
||||
if (node is null || index == -1) return OnNodeLinear(x, y);
|
||||
return (node, index);
|
||||
}
|
||||
|
||||
private void UpdateLastOrderNode()
|
||||
{
|
||||
var oldNavNodes = Waypoints_Value.Skip(LastNavNodeIndex).Take(OnNodeIndex);
|
||||
var lastNavNode = oldNavNodes.LastOrDefault(n => string.IsNullOrEmpty(n.NodeId));
|
||||
if (lastNavNode is null) return;
|
||||
LastOrderNode = OrderNodes.FirstOrDefault(n => n.NodeId == lastNavNode.NodeId);
|
||||
if (LastOrderNode is not null) LastNavNodeIndex = Waypoints_Value.IndexOf(lastNavNode);
|
||||
}
|
||||
|
||||
public double PurePursuit_step(double X_Ref, double Y_Ref, double Angle_Ref)
|
||||
{
|
||||
if (Waypoints_Value is null || Waypoints_Value.Count < 2) return 0;
|
||||
NavigationNode? lookaheadStartPt = null;
|
||||
var (onNode, index) = OnNode(X_Ref, Y_Ref);
|
||||
if (onNode is null || Goal is null) return 0;
|
||||
OnNodeIndex = index;
|
||||
UpdateLastOrderNode();
|
||||
double lookDistance = 0;
|
||||
for (int i = OnNodeIndex + 1; i < Waypoints_Value.IndexOf(Goal); i++)
|
||||
{
|
||||
lookDistance += Math.Sqrt(Math.Pow(Waypoints_Value[i - 1].X - Waypoints_Value[i].X, 2) + Math.Pow(Waypoints_Value[i - 1].Y - Waypoints_Value[i].Y, 2));
|
||||
if (lookDistance >= LookaheadDistance || Waypoints_Value[i].Direction != onNode.Direction)
|
||||
{
|
||||
lookaheadStartPt = Waypoints_Value[i];
|
||||
break;
|
||||
}
|
||||
}
|
||||
lookaheadStartPt ??= Goal;
|
||||
if (onNode.Direction == RobotDirection.BACKWARD)
|
||||
{
|
||||
if (Angle_Ref > Math.PI) Angle_Ref -= Math.PI * 2;
|
||||
else if (Angle_Ref < -Math.PI) Angle_Ref += Math.PI * 2;
|
||||
Angle_Ref += Math.PI;
|
||||
if (Angle_Ref > Math.PI) Angle_Ref -= Math.PI * 2;
|
||||
}
|
||||
var distance = Math.Atan2(lookaheadStartPt.Y - Y_Ref, lookaheadStartPt.X - X_Ref) - Angle_Ref;
|
||||
|
||||
if (Math.Abs(distance) > Math.PI)
|
||||
{
|
||||
double minDistance;
|
||||
if (distance + Math.PI == 0.0) minDistance = 0.0;
|
||||
else
|
||||
{
|
||||
double data = (distance + Math.PI) / (2 * Math.PI);
|
||||
if (data < 0) data = Math.Round(data + 0.5);
|
||||
else data = Math.Round(data - 0.5);
|
||||
minDistance = distance + Math.PI - data * (2 * Math.PI);
|
||||
double checker = 0;
|
||||
if (minDistance != 0.0)
|
||||
{
|
||||
checker = Math.Abs((distance + Math.PI) / (2 * Math.PI));
|
||||
}
|
||||
if (!(Math.Abs(checker - Math.Floor(checker + 0.5)) > 2.2204460492503131E-16 * checker))
|
||||
{
|
||||
minDistance = 0.0;
|
||||
}
|
||||
else if (distance + Math.PI < 0.0)
|
||||
{
|
||||
minDistance += Math.PI * 2;
|
||||
}
|
||||
}
|
||||
if (minDistance == 0.0 && distance + Math.PI > 0.0)
|
||||
{
|
||||
minDistance = Math.PI * 2;
|
||||
}
|
||||
distance = minDistance - Math.PI;
|
||||
}
|
||||
|
||||
var AngularVelocity = 2.0 * 0.5 * Math.Sin(distance) / LookaheadDistance;
|
||||
if (Math.Abs(AngularVelocity) > MaxAngularVelocity)
|
||||
{
|
||||
if (AngularVelocity < 0.0)
|
||||
{
|
||||
AngularVelocity = -1.0;
|
||||
}
|
||||
else if (AngularVelocity > 0.0)
|
||||
{
|
||||
AngularVelocity = 1.0;
|
||||
}
|
||||
else if (AngularVelocity == 0.0)
|
||||
{
|
||||
AngularVelocity = 0.0;
|
||||
}
|
||||
AngularVelocity *= MaxAngularVelocity;
|
||||
}
|
||||
return AngularVelocity;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
using RobotNet10.RobotApp.Interfaces;
|
||||
using RobotNet10.RobotApp.Shared.Enums;
|
||||
|
||||
namespace RobotNet10.RobotApp.Services.Simulation.Navigation;
|
||||
|
||||
public class DifferentialNavigation : SimulationNavigation
|
||||
{
|
||||
private readonly Logger<DifferentialNavigation> Logger;
|
||||
|
||||
public DifferentialNavigation(IServiceProvider ServiceProvider) : base(ServiceProvider)
|
||||
{
|
||||
using var scope = ServiceProvider.CreateScope();
|
||||
Logger = scope.ServiceProvider.GetRequiredService<Logger<DifferentialNavigation>>();
|
||||
}
|
||||
|
||||
private bool IsBackToPath = false;
|
||||
private double? BackToAngle;
|
||||
|
||||
protected override void NavigationHandler()
|
||||
{
|
||||
try
|
||||
{
|
||||
if (NavState == NavigationState.Rotating)
|
||||
{
|
||||
if (RotatePID is not null)
|
||||
{
|
||||
double Error = Visualization.Theta - TargetAngle;
|
||||
if (Error > 180) Error -= 360;
|
||||
else if (Error < -180) Error += 360;
|
||||
if (Math.Abs(Error) < 1)
|
||||
{
|
||||
if(IsBackToPath && BackToAngle.HasValue)
|
||||
{
|
||||
TargetAngle = BackToAngle.Value;
|
||||
BackToAngle = null;
|
||||
IsBackToPath = false;
|
||||
}
|
||||
else if (MovePurePursuit is not null && MovePurePursuit.Waypoints_Value is not null && MovePurePursuit.Waypoints_Value.Count > 2) NavState = NavigationState.Moving;
|
||||
else
|
||||
{
|
||||
NavState = NavigationState.Completed;
|
||||
Dispose();
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
var SpeedCal = RotatePID.PID_step(Error * Math.PI / 180, AngularVelocity, -AngularVelocity, CycleHandlerMilliseconds / 1000.0);
|
||||
VelocityController.SetSpeed(SpeedCal, SpeedCal, CycleHandlerMilliseconds / 1000.0);
|
||||
}
|
||||
}
|
||||
}
|
||||
else if (NavState == NavigationState.Moving)
|
||||
{
|
||||
if (MovePurePursuit is not null && MovePurePursuit.Waypoints_Value is not null && MovePurePursuit?.OrderNodes is not null && MovePurePursuit.OrderNodes.Length > 1 && GoalRotate is not null)
|
||||
{
|
||||
if (MovePID is not null && MoveFuzzy is not null && MovePurePursuit is not null)
|
||||
{
|
||||
var DistanceToGoal = Math.Sqrt(Math.Pow(Visualization.X - MovePurePursuit.OrderNodes[^1].X, 2) + Math.Pow(Visualization.Y - MovePurePursuit.OrderNodes[^1].Y, 2));
|
||||
var DistanceToCheckingNode = Math.Sqrt(Math.Pow(Visualization.X - GoalRotate.X, 2) + Math.Pow(Visualization.Y - GoalRotate.Y, 2));
|
||||
var deviation = GoalRotate.NodeId == MovePurePursuit.OrderNodes[^1].NodeId ? 0.02 : 0.05;
|
||||
if (DistanceToCheckingNode > deviation)
|
||||
{
|
||||
double SpeedTarget = MovePID.PID_step(DistanceToCheckingNode, MaxVelocity, 0, CycleHandlerMilliseconds / 1000.0);
|
||||
double AngularVel = MovePurePursuit.PurePursuit_step(Visualization.X, Visualization.Y, Visualization.Theta * Math.PI / 180);
|
||||
AngularVel *= MovePurePursuit.Waypoints_Value[MovePurePursuit.OnNodeIndex].Direction == RobotDirection.FORWARD ? 1 : -1;
|
||||
(double AngularVelocityLeft, double AngularVelocityRight) = MoveFuzzy.Fuzzy_step(SpeedTarget, AngularVel, CycleHandlerMilliseconds / 1000.0);
|
||||
|
||||
if (MovePurePursuit.Waypoints_Value[MovePurePursuit.OnNodeIndex].Direction == RobotDirection.FORWARD)
|
||||
{
|
||||
AngularVelocityLeft /= PhysicalCog.WheelRadius;
|
||||
AngularVelocityRight = AngularVelocityRight / PhysicalCog.WheelRadius * -1;
|
||||
}
|
||||
else
|
||||
{
|
||||
AngularVelocityLeft = AngularVelocityLeft / PhysicalCog.WheelRadius * -1;
|
||||
AngularVelocityRight /= PhysicalCog.WheelRadius;
|
||||
}
|
||||
VelocityController.SetSpeed(AngularVelocityLeft, AngularVelocityRight, CycleHandlerMilliseconds / 1000.0);
|
||||
}
|
||||
else if (DistanceToGoal < 0.02)
|
||||
{
|
||||
if (MovePurePursuit.OrderNodes[^1].Theta is { } theta)
|
||||
{
|
||||
TargetAngle = theta * 180 / Math.PI;
|
||||
NavState = NavigationState.Rotating;
|
||||
MovePurePursuit = null;
|
||||
}
|
||||
else
|
||||
{
|
||||
NavState = NavigationState.Completed;
|
||||
Dispose();
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
double? targetAngle = null;
|
||||
if (GoalRotate.Theta is { } theta)
|
||||
{
|
||||
targetAngle = theta;
|
||||
ProcessedRotations.Add(GoalRotate.NodeId);
|
||||
BackToAngle = GoalRotate?.ContinueTheta * 180 / Math.PI;
|
||||
IsBackToPath = true;
|
||||
}
|
||||
|
||||
var newGoalRotate = FindNextRotateGoal();
|
||||
if (newGoalRotate is not null && newGoalRotate.NodeId != GoalRotate?.NodeId)
|
||||
{
|
||||
GoalRotate = newGoalRotate;
|
||||
UpdateGoal(newGoalRotate.NodeId);
|
||||
}
|
||||
|
||||
if(targetAngle.HasValue)
|
||||
{
|
||||
TargetAngle = targetAngle.Value * 180 / Math.PI;
|
||||
NavState = NavigationState.Rotating;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
else if (NavState == NavigationState.Paused) VelocityController.Stop();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.Error($"Error in DifferentialNavigation: {ex.Message}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
namespace RobotNet10.RobotApp.Services.Simulation.Navigation;
|
||||
|
||||
public class ForkliftNavigation : SimulationNavigation
|
||||
{
|
||||
private readonly Logger<ForkliftNavigation> Logger;
|
||||
public ForkliftNavigation(IServiceProvider ServiceProvider) : base(ServiceProvider)
|
||||
{
|
||||
using var scope = ServiceProvider.CreateScope();
|
||||
Logger = scope.ServiceProvider.GetRequiredService<Logger<ForkliftNavigation>>();
|
||||
}
|
||||
|
||||
protected override void NavigationHandler()
|
||||
{
|
||||
try
|
||||
{
|
||||
// Implement differential drive navigation logic here
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.Write($"Error in ForkliftNavigationSevice: {ex.Message}", LogLevel.Error);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
using RobotNet10.RobotApp.Shared.Enums;
|
||||
|
||||
namespace RobotNet10.RobotApp.Services.Simulation;
|
||||
|
||||
public class NavigationNode
|
||||
{
|
||||
public Guid Id { get; set; }
|
||||
public string NodeId { get; set; } = string.Empty;
|
||||
public double X { get; set; }
|
||||
public double Y { get; set; }
|
||||
public double? Theta { get; set; }
|
||||
public RobotDirection Direction { get; set; }
|
||||
public double? AllowedDeviationXY { get; set; }
|
||||
public double? AllowedDeviationTheta { get; set; }
|
||||
public double? Speed { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
namespace RobotNet10.RobotApp.Services.Simulation;
|
||||
|
||||
public class SimulationConfig
|
||||
{
|
||||
public bool IsEnable { get; set; }
|
||||
public double MaxVelocity { get; set; }
|
||||
public double MaxAngularVelocity { get; set; }
|
||||
public double Acceleration { get; set; }
|
||||
public double Deceleration { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
namespace RobotNet10.RobotApp.Services.Simulation;
|
||||
|
||||
public static class SimulationExtensions
|
||||
{
|
||||
public static IServiceCollection AddRobotSimulation(this IServiceCollection services)
|
||||
{
|
||||
services.AddSingleton<SimulationVisualization>();
|
||||
return services;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,268 @@
|
||||
using RobotNet.VDA5050.Order;
|
||||
using RobotNet10.Common;
|
||||
using RobotNet10.RobotApp.Detection;
|
||||
using RobotNet10.RobotApp.Interfaces;
|
||||
using RobotNet10.RobotApp.Services.ConfigManager;
|
||||
using RobotNet10.RobotApp.Services.Robot;
|
||||
using RobotNet10.RobotApp.Services.Robot.Models;
|
||||
using RobotNet10.RobotApp.Services.Simulation.Algorithm;
|
||||
using RobotNet10.RobotApp.Shared.Enums;
|
||||
|
||||
namespace RobotNet10.RobotApp.Services.Simulation;
|
||||
|
||||
public class SimulationNavigation : INavigation, IDisposable
|
||||
{
|
||||
public NavigationState State => NavState;
|
||||
public bool Driving => NavDriving;
|
||||
public bool IsReady => true;
|
||||
public double VelocityX => Visualization.Vx;
|
||||
public double VelocityY => Visualization.Vy;
|
||||
public double Omega => Visualization.Omega;
|
||||
|
||||
public event Action<NavigationState>? OnNavigationFinished;
|
||||
|
||||
protected NavigationState NavState = NavigationState.Idle;
|
||||
protected bool NavDriving = false;
|
||||
|
||||
protected const int CycleHandlerMilliseconds = 50;
|
||||
private WatchThread<SimulationNavigation>? NavigationTimer;
|
||||
|
||||
protected double TargetAngle = 0;
|
||||
protected PID? RotatePID;
|
||||
protected readonly double AngularVelocity;
|
||||
|
||||
protected PID? MovePID;
|
||||
protected FuzzyLogic? MoveFuzzy;
|
||||
protected PurePursuit? MovePurePursuit;
|
||||
|
||||
protected readonly SimulationVisualization Visualization;
|
||||
protected readonly SimulationVelocity VelocityController;
|
||||
protected readonly IRobotConfiguration RobotConfiguration;
|
||||
|
||||
protected OrderNode? GoalRotate;
|
||||
protected OrderNode? CurrentBaseNode;
|
||||
protected HashSet<string> ProcessedRotations = [];
|
||||
protected NavigationState ResumeState = NavigationState.Idle;
|
||||
|
||||
protected SimulationConfig SimCog;
|
||||
protected RobotPhysicalConfig PhysicalCog;
|
||||
protected double MaxVelocity;
|
||||
|
||||
private readonly ILogger<SimulationNavigation> Logger;
|
||||
|
||||
public SimulationNavigation(IServiceProvider ServiceProvider)
|
||||
{
|
||||
using var scope = ServiceProvider.CreateScope();
|
||||
Logger = scope.ServiceProvider.GetRequiredService<ILogger<SimulationNavigation>>();
|
||||
Visualization = scope.ServiceProvider.GetRequiredService<SimulationVisualization>();
|
||||
RobotConfiguration = scope.ServiceProvider.GetRequiredService<IRobotConfiguration>();
|
||||
SimCog = RobotConfiguration.GetSimulationConfig();
|
||||
MaxVelocity = SimCog.MaxVelocity;
|
||||
PhysicalCog = RobotConfiguration.GetRobotPhysicalConfig();
|
||||
VelocityController = new(Visualization, SimCog);
|
||||
Visualization.SetPhysical(PhysicalCog.WheelRadius, PhysicalCog.Width);
|
||||
AngularVelocity = SimCog.MaxAngularVelocity * PhysicalCog.Width / 2 / 2 / PhysicalCog.WheelRadius;
|
||||
}
|
||||
|
||||
protected void HandleNavigationStart()
|
||||
{
|
||||
SimCog = RobotConfiguration.GetSimulationConfig();
|
||||
PhysicalCog = RobotConfiguration.GetRobotPhysicalConfig();
|
||||
Visualization.SetPhysical(PhysicalCog.WheelRadius, PhysicalCog.Width);
|
||||
NavigationTimer = new(CycleHandlerMilliseconds, NavigationHandler, Logger);
|
||||
NavigationTimer.Start();
|
||||
}
|
||||
|
||||
protected void HandleNavigationStop()
|
||||
{
|
||||
NavigationTimer?.Dispose();
|
||||
NavigationTimer = null;
|
||||
}
|
||||
|
||||
protected virtual void NavigationHandler() { }
|
||||
|
||||
public void SafetyStop()
|
||||
{
|
||||
NavState = NavigationState.SafetyStop;
|
||||
}
|
||||
|
||||
public void CancelMovement()
|
||||
{
|
||||
NavState = NavigationState.Canceled;
|
||||
Dispose();
|
||||
}
|
||||
|
||||
public void Move(RobotNet.VDA5050.Order.OrderMsg order, bool hasLoad = false)
|
||||
{
|
||||
var nodes = order.Nodes;
|
||||
var edges = order.Edges;
|
||||
NavState = NavigationState.Initializing;
|
||||
|
||||
MovePID = new PID().WithKp(1).WithKi(0.0001).WithKd(0.6);
|
||||
MoveFuzzy = new FuzzyLogic();
|
||||
MovePurePursuit = new PurePursuit()
|
||||
.WithLookheadDistance(0.35)
|
||||
.WithPath(nodes, edges, Visualization.Theta * Math.PI / 180);
|
||||
|
||||
(NavigationNode node, int index) = MovePurePursuit.GetOnNavNode(Visualization.X, Visualization.Y);
|
||||
if (index >= MovePurePursuit.Waypoints_Value.Count - 1) return;
|
||||
|
||||
double angleFoward = Math.Atan2(MovePurePursuit.Waypoints_Value[index + 1].Y - node.Y, MovePurePursuit.Waypoints_Value[index + 1].X - node.X) * 180 / Math.PI;
|
||||
double angleBacward = Math.Atan2(node.Y - MovePurePursuit.Waypoints_Value[index + 1].Y, node.X - MovePurePursuit.Waypoints_Value[index + 1].X) * 180 / Math.PI;
|
||||
Rotate(node.Direction == RobotDirection.FORWARD ? angleFoward : angleBacward);
|
||||
}
|
||||
|
||||
public void MoveStraight(double x, double y, bool hasLoad = false, RobotDirection? direction = null)
|
||||
{
|
||||
//var headRobotNode = new NavigationNode()
|
||||
//{
|
||||
// X = Visualization.X * Math.Acos(Visualization.Theta * Math.PI / 180),
|
||||
// Y = Visualization.Y * Math.Asin(Visualization.Theta * Math.PI / 180),
|
||||
//};
|
||||
//var goalNode = new NavigationNode()
|
||||
//{
|
||||
// NodeId = "RobotGoal",
|
||||
// X = x,
|
||||
// Y = y,
|
||||
//};
|
||||
//var currentRobotNode = new NavigationNode()
|
||||
//{
|
||||
// NodeId = "RobotCurrentNode",
|
||||
// X = Visualization.X,
|
||||
// Y = Visualization.Y,
|
||||
//};
|
||||
//goalNode.Theta = SpaceCompute.GetVectorAngle(currentRobotNode.X, currentRobotNode.Y, headRobotNode.X, headRobotNode.Y, goalNode.X, goalNode.Y) > 90 ?
|
||||
// Math.Atan2(currentRobotNode.Y - goalNode.Y, currentRobotNode.X - goalNode.X) :
|
||||
// Math.Atan2(goalNode.Y - currentRobotNode.Y, goalNode.X - currentRobotNode.X);
|
||||
//currentRobotNode.Theta = goalNode.Theta;
|
||||
|
||||
//MovePID = new PID().WithKp(1.5).WithKi(0.0001).WithKd(0.8);
|
||||
//MoveFuzzy = new FuzzyLogic();
|
||||
//MovePurePursuit = new PurePursuit()
|
||||
// .WithLookheadDistance(0.25)
|
||||
// .WithPath([currentRobotNode, goalNode], [new Edge()
|
||||
//{
|
||||
// EdgeId = "Straight edge",
|
||||
// Trajectory = new Trajectory()
|
||||
// {
|
||||
// Degree = 1,
|
||||
// ControlPoints = []
|
||||
// },
|
||||
// StartNodeId = currentRobotNode.NodeId,
|
||||
// EndNodeId = goalNode.NodeId,
|
||||
//}], Visualization.Theta);
|
||||
|
||||
//double Angle = Math.Atan2(NavigationPath[1].Y - NavigationPath[0].Y, NavigationPath[1].X - NavigationPath[0].X);
|
||||
//Rotate(Angle * 180 / Math.PI);
|
||||
//UpdateOrder(goalNode.NodeId);
|
||||
}
|
||||
|
||||
public void DockTo(IDetectSession session, bool hasLoad = false, RobotDirection? direction = null)
|
||||
{
|
||||
}
|
||||
|
||||
public void Pause()
|
||||
{
|
||||
ResumeState = NavState;
|
||||
NavState = NavigationState.Paused;
|
||||
}
|
||||
|
||||
public void Resume()
|
||||
{
|
||||
NavState = ResumeState;
|
||||
}
|
||||
|
||||
public void Rotate(double angle)
|
||||
{
|
||||
RotatePID = new PID().WithKp(10).WithKi(0.01).WithKd(0.1);
|
||||
TargetAngle = SpaceCompute.NormalizeDegreeAngle(angle);
|
||||
NavState = NavigationState.Rotating;
|
||||
HandleNavigationStart();
|
||||
}
|
||||
|
||||
protected void UpdateGoal(string goalId)
|
||||
{
|
||||
MovePurePursuit?.UpdateGoal(goalId);
|
||||
}
|
||||
|
||||
public void UpdateOrder(string newBaseNodeId)
|
||||
{
|
||||
var newBaseNode = MovePurePursuit?.OrderNodes.FirstOrDefault(n => n.NodeId == newBaseNodeId);
|
||||
if (newBaseNode is not null && newBaseNode.NodeId != CurrentBaseNode?.NodeId)
|
||||
{
|
||||
CurrentBaseNode = newBaseNode;
|
||||
var newGoalRotate = FindNextRotateGoal();
|
||||
if (newGoalRotate is not null && newGoalRotate.NodeId != GoalRotate?.NodeId)
|
||||
{
|
||||
GoalRotate = newGoalRotate;
|
||||
UpdateGoal(newGoalRotate.NodeId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public void RefreshOrder(Node[] nodes, Edge[] edges)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
public void SetSpeed(double speed)
|
||||
{
|
||||
MaxVelocity = speed;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
HandleNavigationStop();
|
||||
VelocityController.Stop();
|
||||
CurrentBaseNode = null;
|
||||
MovePurePursuit = null;
|
||||
MovePID = null;
|
||||
RotatePID = null;
|
||||
NavDriving = false;
|
||||
OnNavigationFinished?.Invoke(NavState);
|
||||
NavState = NavigationState.Idle;
|
||||
GC.SuppressFinalize(this);
|
||||
}
|
||||
|
||||
public void Refresh()
|
||||
{
|
||||
NavState = NavigationState.Idle;
|
||||
}
|
||||
|
||||
public void Start()
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
public void Stop()
|
||||
{
|
||||
Dispose();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tìm node có IsWaitingRotate đầu tiên trong path từ currentNode đến currentGoal
|
||||
/// </summary>
|
||||
protected OrderNode? FindNextRotateGoal()
|
||||
{
|
||||
if (CurrentBaseNode == null || MovePurePursuit is null || MovePurePursuit.OrderNodes.Length == 0) return null;
|
||||
|
||||
int goalIndex = Array.FindIndex(MovePurePursuit.OrderNodes, n => n.NodeId == CurrentBaseNode.NodeId);
|
||||
if (goalIndex == -1) return null;
|
||||
|
||||
int lastNodeIdx = Array.FindIndex(MovePurePursuit.OrderNodes, n => n.NodeId == GoalRotate?.NodeId);
|
||||
lastNodeIdx = lastNodeIdx == -1 ? 0 : lastNodeIdx + 1;
|
||||
|
||||
// Tìm từ node hiện tại đến goal
|
||||
for (int i = lastNodeIdx; i <= goalIndex; i++)
|
||||
{
|
||||
var node = MovePurePursuit.OrderNodes[i];
|
||||
|
||||
// Tìm node có IsWaitingRotate và chưa xử lý
|
||||
if (node.IsWaitRotating && !ProcessedRotations.Contains(node.NodeId))
|
||||
{
|
||||
return node;
|
||||
}
|
||||
}
|
||||
return CurrentBaseNode;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
using RobotNet10.RobotApp.Services.Simulation.Navigation;
|
||||
using RobotNet10.RobotApp.Shared.Enums;
|
||||
|
||||
namespace RobotNet10.RobotApp.Services.Simulation;
|
||||
|
||||
public class SimulationNavigationManager
|
||||
{
|
||||
public static SimulationNavigation GetNavigation(NavigationType type, IServiceProvider ServiceProvider)
|
||||
{
|
||||
if (type == NavigationType.Forklift) return new ForkliftNavigation(ServiceProvider);
|
||||
return new DifferentialNavigation(ServiceProvider);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
namespace RobotNet10.RobotApp.Services.Simulation;
|
||||
|
||||
public class SimulationVelocity(SimulationVisualization Visualization, SimulationConfig Model)
|
||||
{
|
||||
private readonly double Acceleration = Model.Acceleration;
|
||||
private readonly double Deceleration = Model.Deceleration;
|
||||
private double AngularVelLeft;
|
||||
private double AngularVelRight;
|
||||
|
||||
private (double angularVelLeft, double angularVelRight) AccelerationCalculator(double wL, double wR, double wL_Current, double wR_Current)
|
||||
{
|
||||
var angularVelLeft = wL_Current;
|
||||
var angularVelRight = wR_Current;
|
||||
if (wL_Current == 0 || wL / wL_Current < 0)
|
||||
{
|
||||
if (wL != 0) angularVelLeft += wL / Math.Abs(wL) * Acceleration;
|
||||
else angularVelLeft = wL;
|
||||
}
|
||||
else
|
||||
{
|
||||
if (Math.Abs(wL) - Math.Abs(wL_Current) > Acceleration) angularVelLeft += Acceleration * wL_Current / Math.Abs(wL_Current);
|
||||
else if (Math.Abs(wL_Current) - Math.Abs(wL) > Deceleration) angularVelLeft -= Deceleration * wL_Current / Math.Abs(wL_Current);
|
||||
else angularVelLeft = wL;
|
||||
}
|
||||
|
||||
if (wR_Current == 0 || wR / wR_Current < 0)
|
||||
{
|
||||
if (wR != 0) angularVelRight += wR / Math.Abs(wR) * Acceleration;
|
||||
else angularVelRight = wR;
|
||||
}
|
||||
else
|
||||
{
|
||||
if (Math.Abs(wR) - Math.Abs(wR_Current) > Acceleration) angularVelRight += Acceleration * wR_Current / Math.Abs(wR_Current);
|
||||
else if (Math.Abs(wR_Current) - Math.Abs(wR) > Deceleration) angularVelRight -= Deceleration * wR_Current / Math.Abs(wR_Current);
|
||||
else angularVelRight = wR;
|
||||
}
|
||||
|
||||
if (Math.Abs(angularVelLeft) > Math.Abs(wL)) angularVelLeft = wL;
|
||||
if (Math.Abs(angularVelRight) > Math.Abs(wR)) angularVelRight = wR;
|
||||
return (angularVelLeft, angularVelRight);
|
||||
}
|
||||
|
||||
public bool SetSpeed(double angularVelLeft, double angularVelRight, double sampleTime)
|
||||
{
|
||||
(AngularVelLeft, AngularVelRight) = AccelerationCalculator(angularVelLeft, angularVelRight, AngularVelLeft, AngularVelRight);
|
||||
//Console.WriteLine($"AngularVelLeft = {AngularVelLeft:0.####}, AngularVelRight = {AngularVelRight:0.####}");
|
||||
_ = Visualization.UpdatePosition(AngularVelLeft, AngularVelRight, sampleTime);
|
||||
return true;
|
||||
}
|
||||
|
||||
public void Stop()
|
||||
{
|
||||
(AngularVelLeft, AngularVelRight) = (0, 0);
|
||||
_ = Visualization.UpdatePosition(AngularVelLeft, AngularVelRight, 0.05);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
namespace RobotNet10.RobotApp.Services.Simulation;
|
||||
|
||||
public class SimulationVisualization
|
||||
{
|
||||
public double X { get; private set; }
|
||||
public double Y { get; private set; }
|
||||
public double Theta { get; private set; }
|
||||
public double Vx { get; private set; }
|
||||
public double Vy { get; private set; }
|
||||
public double Omega { get; private set; }
|
||||
|
||||
private double RadiusWheel = 0;
|
||||
private double RadiusRobot = 0;
|
||||
|
||||
public void SetPhysical(double radiusWheel, double robotWidth)
|
||||
{
|
||||
RadiusWheel = radiusWheel;
|
||||
RadiusRobot = robotWidth / 2;
|
||||
}
|
||||
|
||||
public (double x, double y, double angle) UpdatePosition(double wL, double wR, double time)
|
||||
{
|
||||
Theta = (Theta + time * (-wR - wL) * RadiusWheel / RadiusRobot * 180 / Math.PI) % 360;
|
||||
X += time * (-wR + wL) * RadiusWheel * Math.Cos(Theta * Math.PI / 180) / 2;
|
||||
Y += time * (-wR + wL) * RadiusWheel * Math.Sin(Theta * Math.PI / 180) / 2;
|
||||
_ = UpdateVelocity(wL, wR);
|
||||
if (Theta > 180) Theta -= 360;
|
||||
else if (Theta < -180) Theta += 360;
|
||||
return (X, Y, Theta);
|
||||
}
|
||||
|
||||
public (double vx, double vy, double omega) UpdateVelocity(double wL, double wR)
|
||||
{
|
||||
Vx = (-wR + wL) * RadiusWheel / 2;
|
||||
Omega = (-wR - wL) * RadiusWheel / RadiusRobot;
|
||||
return (Vx, 0, Omega);
|
||||
}
|
||||
|
||||
public void LocalizationInitialize(double x, double y, double theta)
|
||||
{
|
||||
X = x;
|
||||
Y = y;
|
||||
Theta = theta;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
namespace RobotNet10.RobotApp.Services.State;
|
||||
|
||||
public enum RobotEventType
|
||||
{
|
||||
// System Events
|
||||
Initialize,
|
||||
InitializeCompleted,
|
||||
Shutdown,
|
||||
ShutdownCompleted,
|
||||
|
||||
// Mode Transition Events
|
||||
EnterAuto,
|
||||
EnterManual,
|
||||
EnterService,
|
||||
EnterStop,
|
||||
EnterFault,
|
||||
ExitFault,
|
||||
|
||||
// Auto Mode Events
|
||||
StartExecution,
|
||||
PauseExecution,
|
||||
ResumeExecution,
|
||||
CancelExecution,
|
||||
CompleteExecution,
|
||||
StartRecovery,
|
||||
CompleteRecovery,
|
||||
RemoteOverride,
|
||||
|
||||
// Execution Events - Moving
|
||||
StartMoving,
|
||||
StartNavigation,
|
||||
StartAvoidance,
|
||||
StartApproach,
|
||||
StartTracking,
|
||||
StartRepositioning,
|
||||
CompleteMoving,
|
||||
|
||||
// Execution Events - ACT
|
||||
StartACT,
|
||||
StartDocking,
|
||||
CompleteDocking,
|
||||
StartCharging,
|
||||
CompleteCharging,
|
||||
StartUndocking,
|
||||
CompleteUndocking,
|
||||
StartLoading,
|
||||
CompleteLoading,
|
||||
StartUnloading,
|
||||
CompleteUnloading,
|
||||
StartTechAction,
|
||||
CompleteTechAction,
|
||||
CompleteACT,
|
||||
|
||||
// Stop Events
|
||||
EmergencyStop,
|
||||
BumperTriggered,
|
||||
ProtectiveStop,
|
||||
ManualStop,
|
||||
ReleaseStop,
|
||||
|
||||
// Fault Events
|
||||
NavigationFault,
|
||||
LocalizationFault,
|
||||
ShielfFault,
|
||||
BatteryFault,
|
||||
DriverFault,
|
||||
PeripheralsFault,
|
||||
SafetyFault,
|
||||
CommunicationFault,
|
||||
FaultResolved,
|
||||
}
|
||||
@@ -0,0 +1,336 @@
|
||||
using Appccelerate.StateMachine;
|
||||
using Appccelerate.StateMachine.AsyncMachine;
|
||||
using Appccelerate.StateMachine.AsyncMachine.Events;
|
||||
|
||||
namespace RobotNet10.RobotApp.Services.State;
|
||||
|
||||
public class RobotStateMachine(Logger<RobotStateMachine> Logger, RobotStateMachineExecute StateExecute)
|
||||
{
|
||||
private AsyncPassiveStateMachine<RobotStateType, RobotEventType>? _stateMachine;
|
||||
private RobotStateType _currentState = RobotStateType.System;
|
||||
public bool IsInitialized { get; private set; } = false;
|
||||
|
||||
public RobotStateType CurrentState => _currentState;
|
||||
public event EventHandler<StateChangedEventArgs>? StateChanged;
|
||||
|
||||
// Dictionary để track hierarchy relationships cho helper methods
|
||||
private readonly Dictionary<RobotStateType, RobotStateType> _stateHierarchies = [];
|
||||
|
||||
public async Task InitializeAsync()
|
||||
{
|
||||
if (IsInitialized) return;
|
||||
|
||||
var builder = new StateMachineDefinitionBuilder<RobotStateType, RobotEventType>();
|
||||
|
||||
// Build hierarchy map (chỉ cần track parent-child relationship)
|
||||
BuildHierarchyMap();
|
||||
|
||||
// ===========================
|
||||
// ROOT LEVEL - Hierarchical States
|
||||
// ===========================
|
||||
|
||||
// System State Hierarchy
|
||||
builder.In(RobotStateType.System)
|
||||
.On(RobotEventType.EnterAuto).Goto(RobotStateType.Auto)
|
||||
.On(RobotEventType.EnterManual).Goto(RobotStateType.Manual)
|
||||
.On(RobotEventType.EnterService).Goto(RobotStateType.Service)
|
||||
.On(RobotEventType.RemoteOverride).Goto(RobotStateType.Remote_Override)
|
||||
.On(RobotEventType.EnterStop).Goto(RobotStateType.Stop)
|
||||
.On(RobotEventType.EnterFault).Goto(RobotStateType.Fault);
|
||||
|
||||
builder.DefineHierarchyOn(RobotStateType.System)
|
||||
.WithHistoryType(HistoryType.Deep)
|
||||
.WithInitialSubState(RobotStateType.Initializing)
|
||||
.WithSubState(RobotStateType.Standby)
|
||||
.WithSubState(RobotStateType.Shutting_Down);
|
||||
|
||||
// Auto State Hierarchy
|
||||
builder.In(RobotStateType.Auto)
|
||||
.On(RobotEventType.EnterManual).Goto(RobotStateType.Manual)
|
||||
.On(RobotEventType.EnterService).Goto(RobotStateType.Service)
|
||||
.On(RobotEventType.RemoteOverride).Goto(RobotStateType.Remote_Override)
|
||||
.On(RobotEventType.EnterStop).Goto(RobotStateType.Stop)
|
||||
.On(RobotEventType.EnterFault).Goto(RobotStateType.Fault);
|
||||
|
||||
builder.DefineHierarchyOn(RobotStateType.Auto)
|
||||
.WithHistoryType(HistoryType.Deep)
|
||||
.WithInitialSubState(RobotStateType.Idle)
|
||||
.WithSubState(RobotStateType.Executing)
|
||||
.WithSubState(RobotStateType.Paused)
|
||||
.WithSubState(RobotStateType.Canceling)
|
||||
.WithSubState(RobotStateType.Recovering);
|
||||
|
||||
// Manual State
|
||||
builder.In(RobotStateType.Manual)
|
||||
.ExecuteOnEntry(() => { OnEnterState(RobotStateType.Manual); StateExecute.EntryManual(); })
|
||||
.ExecuteOnExit(StateExecute.ExitManual)
|
||||
.On(RobotEventType.EnterAuto).Goto(RobotStateType.Auto)
|
||||
.On(RobotEventType.EnterService).Goto(RobotStateType.Service)
|
||||
.On(RobotEventType.RemoteOverride).Goto(RobotStateType.Remote_Override)
|
||||
.On(RobotEventType.EnterStop).Goto(RobotStateType.Stop)
|
||||
.On(RobotEventType.EnterFault).Goto(RobotStateType.Fault);
|
||||
|
||||
// Service State
|
||||
builder.In(RobotStateType.Service)
|
||||
.ExecuteOnEntry(() => { OnEnterState(RobotStateType.Service); StateExecute.EntryService(); })
|
||||
.ExecuteOnExit(StateExecute.ExitService)
|
||||
.On(RobotEventType.EnterAuto).Goto(RobotStateType.Auto)
|
||||
.On(RobotEventType.EnterManual).Goto(RobotStateType.Manual)
|
||||
.On(RobotEventType.RemoteOverride).Goto(RobotStateType.Remote_Override)
|
||||
.On(RobotEventType.EnterStop).Goto(RobotStateType.Stop)
|
||||
.On(RobotEventType.EnterFault).Goto(RobotStateType.Fault);
|
||||
|
||||
// Stop State
|
||||
builder.In(RobotStateType.Stop)
|
||||
.ExecuteOnEntry(() => { OnEnterState(RobotStateType.Stop); StateExecute.EntryStop(); })
|
||||
.ExecuteOnExit(StateExecute.ExitStop)
|
||||
.On(RobotEventType.ReleaseStop).Goto(RobotStateType.System)
|
||||
.On(RobotEventType.EnterFault).Goto(RobotStateType.Fault);
|
||||
|
||||
// Fault State
|
||||
builder.In(RobotStateType.Fault)
|
||||
.ExecuteOnEntry(() => { OnEnterState(RobotStateType.Fault); StateExecute.EntryFault(); })
|
||||
.ExecuteOnExit(StateExecute.ExitFault)
|
||||
.On(RobotEventType.ExitFault).Goto(RobotStateType.System);
|
||||
|
||||
// Remote_Override State (top-level, peer of Service)
|
||||
builder.In(RobotStateType.Remote_Override)
|
||||
.ExecuteOnEntry(() => { OnEnterState(RobotStateType.Remote_Override); StateExecute.EntryRemoteOverride(); })
|
||||
.ExecuteOnExit(StateExecute.ExitRemoteOverride)
|
||||
.On(RobotEventType.EnterAuto).Goto(RobotStateType.Auto)
|
||||
.On(RobotEventType.EnterManual).Goto(RobotStateType.Manual)
|
||||
.On(RobotEventType.EnterService).Goto(RobotStateType.Service)
|
||||
.On(RobotEventType.EnterStop).Goto(RobotStateType.Stop)
|
||||
.On(RobotEventType.EnterFault).Goto(RobotStateType.Fault);
|
||||
|
||||
// ===========================
|
||||
// SYSTEM SUB-STATES
|
||||
// ===========================
|
||||
|
||||
builder.In(RobotStateType.Initializing)
|
||||
.ExecuteOnEntry(() => { OnEnterState(RobotStateType.Initializing); StateExecute.EntryInitializing(); })
|
||||
.On(RobotEventType.InitializeCompleted).Goto(RobotStateType.Standby)
|
||||
.On(RobotEventType.EnterFault).Goto(RobotStateType.Fault);
|
||||
|
||||
builder.In(RobotStateType.Standby)
|
||||
.ExecuteOnEntry(() => { OnEnterState(RobotStateType.Standby); StateExecute.EntryStandby(); })
|
||||
.On(RobotEventType.Shutdown).Goto(RobotStateType.Shutting_Down);
|
||||
|
||||
builder.In(RobotStateType.Shutting_Down)
|
||||
.ExecuteOnEntry(() => { OnEnterState(RobotStateType.Shutting_Down); StateExecute.EntryShuttingDown(); })
|
||||
.ExecuteOnExit(StateExecute.ExitShuttingDown)
|
||||
.On(RobotEventType.ShutdownCompleted).Goto(RobotStateType.Standby);
|
||||
|
||||
// ===========================
|
||||
// AUTO SUB-STATES
|
||||
// ===========================
|
||||
|
||||
builder.In(RobotStateType.Idle)
|
||||
.ExecuteOnEntry(() => { OnEnterState(RobotStateType.Idle); StateExecute.EntryIdle(); })
|
||||
.On(RobotEventType.StartExecution).Goto(RobotStateType.Executing);
|
||||
|
||||
// Executing State Hierarchy
|
||||
builder.In(RobotStateType.Executing)
|
||||
.ExecuteOnEntry(() => { OnEnterState(RobotStateType.Executing); StateExecute.EntryExecuting(); })
|
||||
.ExecuteOnExit(StateExecute.ExitExecuting)
|
||||
.On(RobotEventType.PauseExecution).Goto(RobotStateType.Paused)
|
||||
.On(RobotEventType.CancelExecution).Goto(RobotStateType.Canceling)
|
||||
.On(RobotEventType.CompleteExecution).Goto(RobotStateType.Idle);
|
||||
|
||||
builder.DefineHierarchyOn(RobotStateType.Executing)
|
||||
.WithHistoryType(HistoryType.Deep)
|
||||
.WithInitialSubState(RobotStateType.Moving)
|
||||
.WithSubState(RobotStateType.ACT);
|
||||
|
||||
builder.In(RobotStateType.Paused)
|
||||
.ExecuteOnEntry(() => { OnEnterState(RobotStateType.Paused); StateExecute.EntryPaused(); })
|
||||
.ExecuteOnExit(StateExecute.ExitPaused)
|
||||
.On(RobotEventType.ResumeExecution).Goto(RobotStateType.Executing)
|
||||
.On(RobotEventType.CancelExecution).Goto(RobotStateType.Canceling);
|
||||
|
||||
builder.In(RobotStateType.Canceling)
|
||||
.ExecuteOnEntry(() => { OnEnterState(RobotStateType.Canceling); StateExecute.EntryCanceling(); })
|
||||
.ExecuteOnExit(StateExecute.ExitCanceling)
|
||||
.On(RobotEventType.CompleteExecution).Goto(RobotStateType.Idle);
|
||||
|
||||
builder.In(RobotStateType.Recovering)
|
||||
.ExecuteOnEntry(() => { OnEnterState(RobotStateType.Recovering); StateExecute.EntryRecovering(); })
|
||||
.On(RobotEventType.CompleteRecovery).Goto(RobotStateType.Idle);
|
||||
|
||||
// ===========================
|
||||
// EXECUTING SUB-STATES
|
||||
// ===========================
|
||||
|
||||
// Moving State Hierarchy
|
||||
builder.In(RobotStateType.Moving)
|
||||
.ExecuteOnEntry(() => { OnEnterState(RobotStateType.Moving); StateExecute.EntryMoving(); })
|
||||
.ExecuteOnExit(StateExecute.ExitMoving)
|
||||
.On(RobotEventType.StartACT).Goto(RobotStateType.ACT)
|
||||
.On(RobotEventType.CompleteMoving).Goto(RobotStateType.Idle);
|
||||
|
||||
// ACT State Hierarchy
|
||||
builder.In(RobotStateType.ACT)
|
||||
.ExecuteOnEntry(() => { OnEnterState(RobotStateType.ACT); StateExecute.EntryACT(); })
|
||||
.ExecuteOnExit(StateExecute.ExitACT)
|
||||
.On(RobotEventType.StartMoving).Goto(RobotStateType.Moving)
|
||||
.On(RobotEventType.CompleteACT).Goto(RobotStateType.Idle);
|
||||
|
||||
builder.DefineHierarchyOn(RobotStateType.ACT)
|
||||
.WithHistoryType(HistoryType.Deep)
|
||||
.WithInitialSubState(RobotStateType.Docking)
|
||||
.WithSubState(RobotStateType.Docked)
|
||||
.WithSubState(RobotStateType.Charging)
|
||||
.WithSubState(RobotStateType.Undocking)
|
||||
.WithSubState(RobotStateType.Loading)
|
||||
.WithSubState(RobotStateType.Unloading)
|
||||
.WithSubState(RobotStateType.TechAction);
|
||||
|
||||
// ===========================
|
||||
// ACT SUB-STATES
|
||||
// ===========================
|
||||
|
||||
builder.In(RobotStateType.Docking)
|
||||
.ExecuteOnEntry(() => { OnEnterState(RobotStateType.Docking); StateExecute.EntryDocking(); })
|
||||
.On(RobotEventType.CompleteDocking).Goto(RobotStateType.Docked);
|
||||
|
||||
builder.In(RobotStateType.Docked)
|
||||
.ExecuteOnEntry(() => { OnEnterState(RobotStateType.Docked); StateExecute.EntryDocked(); })
|
||||
.On(RobotEventType.StartCharging).Goto(RobotStateType.Charging)
|
||||
.On(RobotEventType.StartUndocking).Goto(RobotStateType.Undocking)
|
||||
.On(RobotEventType.StartLoading).Goto(RobotStateType.Loading)
|
||||
.On(RobotEventType.StartUnloading).Goto(RobotStateType.Unloading);
|
||||
|
||||
builder.In(RobotStateType.Charging)
|
||||
.ExecuteOnEntry(() => { OnEnterState(RobotStateType.Charging); StateExecute.EntryCharging(); })
|
||||
.ExecuteOnExit(StateExecute.ExitCharging)
|
||||
.On(RobotEventType.CompleteCharging).Goto(RobotStateType.Docked);
|
||||
|
||||
builder.In(RobotStateType.Undocking)
|
||||
.ExecuteOnEntry(() => { OnEnterState(RobotStateType.Undocking); StateExecute.EntryUndocking(); })
|
||||
.On(RobotEventType.CompleteUndocking).Goto(RobotStateType.Docking);
|
||||
|
||||
builder.In(RobotStateType.Loading)
|
||||
.ExecuteOnEntry(() => { OnEnterState(RobotStateType.Loading); StateExecute.EntryLoading(); })
|
||||
.On(RobotEventType.CompleteLoading).Goto(RobotStateType.Docked);
|
||||
|
||||
builder.In(RobotStateType.Unloading)
|
||||
.ExecuteOnEntry(() => { OnEnterState(RobotStateType.Unloading); StateExecute.EntryUnloading(); })
|
||||
.On(RobotEventType.CompleteUnloading).Goto(RobotStateType.Docked);
|
||||
|
||||
builder.In(RobotStateType.TechAction)
|
||||
.ExecuteOnEntry(() => { OnEnterState(RobotStateType.TechAction); StateExecute.EntryTechAction(); })
|
||||
.On(RobotEventType.CompleteTechAction).Goto(RobotStateType.Docked);
|
||||
|
||||
// ===========================
|
||||
// CREATE STATE MACHINE
|
||||
// ===========================
|
||||
|
||||
_stateMachine = builder
|
||||
.WithInitialState(RobotStateType.System)
|
||||
.Build()
|
||||
.CreatePassiveStateMachine();
|
||||
|
||||
// Subscribe to state change events
|
||||
_stateMachine.TransitionCompleted += OnTransitionCompleted;
|
||||
|
||||
// Set IsInitialized = true TRƯỚC khi Start() để tránh deadlock
|
||||
// Vì EntryInitializing() có thể gọi ModuleInitializeAsync() chờ IsInitialized
|
||||
IsInitialized = true;
|
||||
|
||||
// Start state machine
|
||||
await _stateMachine.Start();
|
||||
|
||||
Logger.Info($"State Machine initialized successfully with current state: {CurrentState}");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Build hierarchy map - chỉ để track parent-child relationships cho helper methods
|
||||
/// Không cần build transitions vì Appccelerate tự động xử lý
|
||||
/// </summary>
|
||||
private void BuildHierarchyMap()
|
||||
{
|
||||
// System sub-states
|
||||
_stateHierarchies[RobotStateType.Initializing] = RobotStateType.System;
|
||||
_stateHierarchies[RobotStateType.Standby] = RobotStateType.System;
|
||||
_stateHierarchies[RobotStateType.Shutting_Down] = RobotStateType.System;
|
||||
|
||||
// Auto sub-states
|
||||
_stateHierarchies[RobotStateType.Idle] = RobotStateType.Auto;
|
||||
_stateHierarchies[RobotStateType.Executing] = RobotStateType.Auto;
|
||||
_stateHierarchies[RobotStateType.Paused] = RobotStateType.Auto;
|
||||
_stateHierarchies[RobotStateType.Canceling] = RobotStateType.Auto;
|
||||
_stateHierarchies[RobotStateType.Recovering] = RobotStateType.Auto;
|
||||
|
||||
// Executing sub-states
|
||||
_stateHierarchies[RobotStateType.Moving] = RobotStateType.Executing;
|
||||
_stateHierarchies[RobotStateType.ACT] = RobotStateType.Executing;
|
||||
|
||||
// ACT sub-states
|
||||
_stateHierarchies[RobotStateType.Docking] = RobotStateType.ACT;
|
||||
_stateHierarchies[RobotStateType.Docked] = RobotStateType.ACT;
|
||||
_stateHierarchies[RobotStateType.Charging] = RobotStateType.ACT;
|
||||
_stateHierarchies[RobotStateType.Undocking] = RobotStateType.ACT;
|
||||
_stateHierarchies[RobotStateType.Loading] = RobotStateType.ACT;
|
||||
_stateHierarchies[RobotStateType.Unloading] = RobotStateType.ACT;
|
||||
_stateHierarchies[RobotStateType.TechAction] = RobotStateType.ACT;
|
||||
}
|
||||
|
||||
public void Initialize() => InitializeAsync().GetAwaiter().GetResult();
|
||||
|
||||
public async Task FireAsync(RobotEventType eventType)
|
||||
{
|
||||
if (!IsInitialized)
|
||||
{
|
||||
Logger.Warning("State Machine not initialized!");
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
if (_stateMachine != null)
|
||||
{
|
||||
await _stateMachine.Fire(eventType);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.Error($"Fire event {eventType} error: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
public void Fire(RobotEventType eventType) => FireAsync(eventType).GetAwaiter().GetResult();
|
||||
|
||||
public bool IsInState(RobotStateType state)
|
||||
{
|
||||
if (!IsInitialized) return false;
|
||||
if (CurrentState == state) return true;
|
||||
if(_stateHierarchies.TryGetValue(CurrentState, out RobotStateType parentState))
|
||||
{
|
||||
if(parentState == state) return true;
|
||||
|
||||
while (_stateHierarchies.TryGetValue(parentState, out parentState))
|
||||
{
|
||||
if (parentState == state) return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private void OnEnterState(RobotStateType state)
|
||||
{
|
||||
_currentState = state;
|
||||
}
|
||||
|
||||
private void OnTransitionCompleted(object? sender, TransitionCompletedEventArgs<RobotStateType, RobotEventType> e)
|
||||
{
|
||||
Logger.Info($"State Transition: {e.StateId} -> Event: {e.EventId}");
|
||||
StateChanged?.Invoke(this, new StateChangedEventArgs(e.StateId, e.EventId));
|
||||
}
|
||||
}
|
||||
|
||||
public class StateChangedEventArgs(RobotStateType newState, RobotEventType eventType) : EventArgs
|
||||
{
|
||||
public RobotStateType NewState { get; } = newState;
|
||||
public RobotEventType EventType { get; } = eventType;
|
||||
}
|
||||
@@ -0,0 +1,890 @@
|
||||
using System.Threading;
|
||||
using RobotNet10.RobotApp.Interfaces;
|
||||
using RobotNet10.RobotApp.Motion;
|
||||
using RobotNet10.RobotApp.Services.Robot;
|
||||
using RobotNet10.Shared.Geometry;
|
||||
|
||||
namespace RobotNet10.RobotApp.Services.State;
|
||||
|
||||
/// <summary>
|
||||
/// Class chứa các implementation methods cho Entry/Exit actions của State Machine
|
||||
/// Sử dụng IServiceProvider để có thể inject các services khác khi cần
|
||||
/// </summary>
|
||||
public class RobotStateMachineExecute(IServiceScopeFactory ServiceScopeFactory, IPlcController PlcController, Logger<RobotStateMachineExecute> Logger)
|
||||
{
|
||||
// ===========================
|
||||
// SYSTEM STATE HANDLERS
|
||||
// ===========================
|
||||
|
||||
/// <summary>
|
||||
/// Entry: System -> Initializing
|
||||
/// Thực hiện khởi tạo các component của robot
|
||||
/// </summary>
|
||||
public async Task EntryInitializingAsync()
|
||||
{
|
||||
Logger.Info("==> Entry Initializing State");
|
||||
|
||||
try
|
||||
{
|
||||
// TODO: Khởi tạo các services
|
||||
// - Kiểm tra kết nối phần cứng
|
||||
// - Khởi tạo Driver
|
||||
// - Khởi tạo Sensors
|
||||
// - Load cấu hình
|
||||
|
||||
using var scope = ServiceScopeFactory.CreateAsyncScope();
|
||||
var robotController = scope.ServiceProvider.GetRequiredService<RobotController>();
|
||||
await robotController.ModuleInitializeAsync();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.Error($"Initialization error: {ex.Message}");
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
public void EntryInitializing() => EntryInitializingAsync().GetAwaiter().GetResult();
|
||||
|
||||
/// <summary>
|
||||
/// Entry: System -> Standby
|
||||
/// Robot đã sẵn sàng hoạt động
|
||||
/// </summary>
|
||||
public void EntryStandby()
|
||||
{
|
||||
Logger.Info("==> Entry Standby State");
|
||||
PlcController.SetSystemState(SystemState.IDLE);
|
||||
|
||||
using var scope = ServiceScopeFactory.CreateAsyncScope();
|
||||
var stateService = scope.ServiceProvider.GetRequiredService<RobotStates>();
|
||||
var visualizationService = scope.ServiceProvider.GetRequiredService<RobotVisualization>();
|
||||
stateService.Start();
|
||||
visualizationService.Start();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Entry: System -> Shutting_Down
|
||||
/// Bắt đầu quá trình tắt máy
|
||||
/// </summary>
|
||||
public async Task EntryShuttingDownAsync()
|
||||
{
|
||||
Logger.Info("==> Entry Shutting Down State");
|
||||
|
||||
try
|
||||
{
|
||||
// TODO:
|
||||
// - Lưu trạng thái hiện tại
|
||||
// - Dừng tất cả các task đang chạy
|
||||
// - Ngắt kết nối an toàn
|
||||
|
||||
using var scope = ServiceScopeFactory.CreateAsyncScope();
|
||||
var robotController = scope.ServiceProvider.GetRequiredService<RobotController>();
|
||||
robotController.StopHandler();
|
||||
|
||||
Logger.Info("Shutdown completed");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.Error($"Shutdown error: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
public void EntryShuttingDown() => EntryShuttingDownAsync().GetAwaiter().GetResult();
|
||||
|
||||
/// <summary>
|
||||
/// Exit: System -> Shutting_Down
|
||||
/// </summary>
|
||||
public void ExitShuttingDown()
|
||||
{
|
||||
Logger.Info("<== Exit Shutting Down State");
|
||||
}
|
||||
|
||||
// ===========================
|
||||
// AUTO MODE STATE HANDLERS
|
||||
// ===========================
|
||||
|
||||
/// <summary>
|
||||
/// Entry: Auto -> Idle
|
||||
/// Robot sẵn sàng nhận nhiệm vụ mới
|
||||
/// </summary>
|
||||
public void EntryIdle()
|
||||
{
|
||||
Logger.Info("==> Entry Idle State");
|
||||
PlcController.SetSystemState(SystemState.IDLE);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Entry: Auto -> Executing
|
||||
/// Bắt đầu thực hiện nhiệm vụ
|
||||
/// </summary>
|
||||
public void EntryExecuting()
|
||||
{
|
||||
Logger.Info("==> Entry Executing State");
|
||||
using var scope = ServiceScopeFactory.CreateAsyncScope();
|
||||
var PlcController = scope.ServiceProvider.GetRequiredService<IPlcController>();
|
||||
PlcController.SetOperationState(OperationState.Move);
|
||||
// TODO:
|
||||
// - Lấy nhiệm vụ từ queue
|
||||
// - Khởi tạo execution context
|
||||
// - Bắt đầu timer theo dõi
|
||||
// - Cập nhật VDA5050 state = "EXECUTING"
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Exit: Auto -> Executing
|
||||
/// </summary>
|
||||
public void ExitExecuting()
|
||||
{
|
||||
Logger.Info("<== Exit Executing State");
|
||||
|
||||
using var scope = ServiceScopeFactory.CreateAsyncScope();
|
||||
var PlcController = scope.ServiceProvider.GetRequiredService<IPlcController>();
|
||||
PlcController.SetOperationState(OperationState.None);
|
||||
|
||||
// TODO:
|
||||
// - Cleanup execution context
|
||||
// - Dừng timer
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Entry: Auto -> Paused
|
||||
/// Tạm dừng thực hiện nhiệm vụ
|
||||
/// </summary>
|
||||
public void EntryPaused()
|
||||
{
|
||||
Logger.Info("==> Entry Paused State");
|
||||
|
||||
// TODO:
|
||||
// - Lưu trạng thái hiện tại
|
||||
// - Dừng robot
|
||||
// - Cập nhật VDA5050 state = "PAUSED"
|
||||
|
||||
// Dừng robot
|
||||
StopRobot();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Exit: Auto -> Paused
|
||||
/// Resume từ trạng thái pause
|
||||
/// </summary>
|
||||
public void ExitPaused()
|
||||
{
|
||||
Logger.Info("<== Exit Paused State");
|
||||
|
||||
// TODO:
|
||||
// - Khôi phục trạng thái
|
||||
// - Chuẩn bị tiếp tục thực hiện
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Entry: Auto -> Canceling
|
||||
/// Đang hủy nhiệm vụ
|
||||
/// </summary>
|
||||
public void EntryCanceling()
|
||||
{
|
||||
Logger.Info("==> Entry Canceling State");
|
||||
|
||||
// TODO:
|
||||
// - Dừng robot ngay lập tức
|
||||
// - Hủy tất cả các task con
|
||||
// - Cleanup resources
|
||||
|
||||
StopRobot();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Exit: Auto -> Canceling
|
||||
/// Hoàn tất việc hủy
|
||||
/// </summary>
|
||||
public void ExitCanceling()
|
||||
{
|
||||
Logger.Info("<== Exit Canceling State");
|
||||
|
||||
// TODO:
|
||||
// - Gửi thông báo nhiệm vụ đã hủy
|
||||
// - Reset execution context
|
||||
// - Cập nhật VDA5050 với error/cancelled
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Entry: Auto -> Recovering
|
||||
/// Đang khôi phục từ lỗi
|
||||
/// </summary>
|
||||
public async Task EntryRecoveringAsync()
|
||||
{
|
||||
Logger.Info("==> Entry Recovering State");
|
||||
|
||||
try
|
||||
{
|
||||
// TODO:
|
||||
// - Phân tích lỗi
|
||||
// - Thực hiện recovery procedure
|
||||
// - Kiểm tra trạng thái hệ thống
|
||||
|
||||
Logger.Info("Analyzing error...");
|
||||
await Task.Delay(100);
|
||||
|
||||
Logger.Info("Recovering system...");
|
||||
await Task.Delay(500);
|
||||
|
||||
Logger.Info("Recovery succeeded");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.Error($"Recovery error: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
public void EntryRecovering() => EntryRecoveringAsync().GetAwaiter().GetResult();
|
||||
|
||||
/// <summary>
|
||||
/// Entry: Auto -> Remote_Override (OVERRIDE)
|
||||
/// Chuyển sang chế độ điều khiển từ xa - cho phép override tất cả safety
|
||||
/// </summary>
|
||||
public void EntryRemoteOverride()
|
||||
{
|
||||
Logger.Info("==> Entry Remote Override State (OVERRIDE)");
|
||||
PlcController.SetSystemState(SystemState.OVERRIDE);
|
||||
StopRobot();
|
||||
|
||||
// Set ManualControlService state to Override - cho phép điều khiển với full override
|
||||
try
|
||||
{
|
||||
using var scope = ServiceScopeFactory.CreateScope();
|
||||
var manualControlService = scope.ServiceProvider.GetRequiredService<ManualControlService>();
|
||||
manualControlService.SetState(ManualControlState.Override);
|
||||
Logger.Info("ManualControlService set to Override state");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.Error($"Error setting ManualControlService to Override: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Exit: Auto -> Remote_Override (OVERRIDE)
|
||||
/// </summary>
|
||||
public void ExitRemoteOverride()
|
||||
{
|
||||
Logger.Info("<== Exit Remote Override State (OVERRIDE)");
|
||||
|
||||
// Clear ManualControlService external state
|
||||
try
|
||||
{
|
||||
using var scope = ServiceScopeFactory.CreateScope();
|
||||
var manualControlService = scope.ServiceProvider.GetRequiredService<ManualControlService>();
|
||||
manualControlService.ClearExternalState();
|
||||
Logger.Info("ManualControlService external state cleared");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.Error($"Error clearing ManualControlService external state: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
// ===========================
|
||||
// EXECUTING - MOVING STATE HANDLERS
|
||||
// ===========================
|
||||
|
||||
/// <summary>
|
||||
/// Entry: Executing -> Moving
|
||||
/// Bắt đầu di chuyển
|
||||
/// </summary>
|
||||
public void EntryMoving()
|
||||
{
|
||||
Logger.Info("==> Entry Moving State");
|
||||
PlcController.SetSystemState(SystemState.PROCCESSING);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Exit: Executing -> Moving
|
||||
/// </summary>
|
||||
public void ExitMoving()
|
||||
{
|
||||
Logger.Info("<== Exit Moving State");
|
||||
|
||||
// TODO:
|
||||
// - Dừng robot
|
||||
// - Lưu vị trí cuối cùng
|
||||
|
||||
StopRobot();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Entry: Moving -> Navigation
|
||||
/// Chế độ navigation bình thường
|
||||
/// </summary>
|
||||
public void EntryNavigation()
|
||||
{
|
||||
Logger.Info("==> Entry Navigation State");
|
||||
|
||||
// TODO:
|
||||
// - Load path từ planner
|
||||
// - Bắt đầu path following
|
||||
// - Monitor obstacles
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Entry: Moving -> Avoidance
|
||||
/// Đang tránh chướng ngại vật
|
||||
/// </summary>
|
||||
public void EntryAvoidance()
|
||||
{
|
||||
Logger.Info("==> Entry Avoidance State");
|
||||
|
||||
// TODO:
|
||||
// - Giảm tốc độ
|
||||
// - Tính toán đường tránh
|
||||
// - Theo dõi obstacle
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Exit: Moving -> Avoidance
|
||||
/// </summary>
|
||||
public void ExitAvoidance()
|
||||
{
|
||||
Logger.Info("<== Exit Avoidance State");
|
||||
|
||||
// TODO:
|
||||
// - Quay lại tốc độ bình thường
|
||||
// - Resume path chính
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Entry: Moving -> Approach
|
||||
/// Tiếp cận mục tiêu (precision mode)
|
||||
/// </summary>
|
||||
public void EntryApproach()
|
||||
{
|
||||
Logger.Info("==> Entry Approach State");
|
||||
|
||||
// TODO:
|
||||
// - Chuyển sang precision mode
|
||||
// - Giảm tốc độ tối đa
|
||||
// - Sử dụng sensors chính xác cao
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Entry: Moving -> Tracking
|
||||
/// Theo dõi mục tiêu động
|
||||
/// </summary>
|
||||
public void EntryTracking()
|
||||
{
|
||||
Logger.Info("==> Entry Tracking State");
|
||||
|
||||
// TODO:
|
||||
// - Bật target tracking
|
||||
// - Theo dõi vị trí mục tiêu
|
||||
// - Điều chỉnh trajectory theo realtime
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Entry: Moving -> Repositioning
|
||||
/// Điều chỉnh lại vị trí
|
||||
/// </summary>
|
||||
public void EntryRepositioning()
|
||||
{
|
||||
Logger.Info("==> Entry Repositioning State");
|
||||
|
||||
// TODO:
|
||||
// - Tính toán vị trí mong muốn
|
||||
// - Di chuyển điều chỉnh nhỏ
|
||||
// - Kiểm tra orientation
|
||||
}
|
||||
|
||||
// ===========================
|
||||
// EXECUTING - ACT STATE HANDLERS
|
||||
// ===========================
|
||||
|
||||
/// <summary>
|
||||
/// Entry: Executing -> ACT
|
||||
/// Bắt đầu thực hiện action
|
||||
/// </summary>
|
||||
public void EntryACT()
|
||||
{
|
||||
Logger.Info("==> Entry ACT State");
|
||||
|
||||
// TODO:
|
||||
// - Dừng robot hoàn toàn
|
||||
// - Chuẩn bị thực hiện action
|
||||
// - Kiểm tra vị trí chính xác
|
||||
|
||||
StopRobot();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Exit: Executing -> ACT
|
||||
/// </summary>
|
||||
public void ExitACT()
|
||||
{
|
||||
Logger.Info("<== Exit ACT State");
|
||||
|
||||
// TODO:
|
||||
// - Cleanup action resources
|
||||
// - Kiểm tra kết quả action
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Entry: ACT -> Docking
|
||||
/// Đang thực hiện docking
|
||||
/// </summary>
|
||||
public async Task EntryDockingAsync()
|
||||
{
|
||||
Logger.Info("==> Entry Docking State");
|
||||
PlcController.SetSystemState(SystemState.DOCKING);
|
||||
|
||||
try
|
||||
{
|
||||
// TODO:
|
||||
// - Tìm vị trí dock station
|
||||
// - Align với dock
|
||||
// - Di chuyển vào dock từ từ
|
||||
|
||||
Logger.Info("Searching for dock station...");
|
||||
await Task.Delay(200);
|
||||
|
||||
Logger.Info("Aligning with dock...");
|
||||
await Task.Delay(300);
|
||||
|
||||
Logger.Info("Moving into dock...");
|
||||
await Task.Delay(500);
|
||||
|
||||
Logger.Info("Docking succeeded");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.Error($"Docking error: {ex.Message}");
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
public void EntryDocking() => EntryDockingAsync().GetAwaiter().GetResult();
|
||||
|
||||
/// <summary>
|
||||
/// Entry: ACT -> Docked
|
||||
/// Đã docked thành công
|
||||
/// </summary>
|
||||
public void EntryDocked()
|
||||
{
|
||||
Logger.Info("==> Entry Docked State");
|
||||
PlcController.SetSystemState(SystemState.DOCKING);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Entry: ACT -> Charging
|
||||
/// Đang sạc pin
|
||||
/// </summary>
|
||||
public async Task EntryChargingAsync()
|
||||
{
|
||||
Logger.Info("==> Entry Charging State");
|
||||
PlcController.SetSystemState(SystemState.CHARGING);
|
||||
|
||||
try
|
||||
{
|
||||
// TODO:
|
||||
// - Bắt đầu charging
|
||||
// - Monitor battery level
|
||||
// - Monitor charging current/voltage
|
||||
|
||||
Logger.Info("Connecting charging power...");
|
||||
await Task.Delay(100);
|
||||
|
||||
Logger.Info("Starting battery charging...");
|
||||
// Charging loop sẽ được xử lý bởi battery service
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.Error($"Charging error: {ex.Message}");
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
public void EntryCharging() => EntryChargingAsync().GetAwaiter().GetResult();
|
||||
|
||||
/// <summary>
|
||||
/// Exit: ACT -> Charging
|
||||
/// </summary>
|
||||
public void ExitCharging()
|
||||
{
|
||||
Logger.Info("<== Exit Charging State");
|
||||
|
||||
// TODO:
|
||||
// - Dừng charging
|
||||
// - Ngắt kết nối nguồn an toàn
|
||||
// - Log battery level
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Entry: ACT -> Undocking
|
||||
/// Đang rời khỏi dock
|
||||
/// </summary>
|
||||
public async Task EntryUndockingAsync()
|
||||
{
|
||||
Logger.Info("==> Entry Undocking State");
|
||||
PlcController.SetSystemState(SystemState.DOCKING);
|
||||
|
||||
try
|
||||
{
|
||||
// TODO:
|
||||
// - Kiểm tra an toàn
|
||||
// - Ngắt kết nối với dock
|
||||
// - Di chuyển ra khỏi dock
|
||||
|
||||
Logger.Info("Checking safety...");
|
||||
await Task.Delay(100);
|
||||
|
||||
Logger.Info("Disconnecting dock...");
|
||||
await Task.Delay(200);
|
||||
|
||||
Logger.Info("Moving out of dock...");
|
||||
await Task.Delay(500);
|
||||
|
||||
Logger.Info("Undocking succeeded");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.Error($"Undocking error: {ex.Message}");
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
public void EntryUndocking() => EntryUndockingAsync().GetAwaiter().GetResult();
|
||||
|
||||
/// <summary>
|
||||
/// Entry: ACT -> Loading
|
||||
/// Đang tải hàng
|
||||
/// </summary>
|
||||
public async Task EntryLoadingAsync()
|
||||
{
|
||||
Logger.Info("==> Entry Loading State");
|
||||
PlcController.SetSystemState(SystemState.PROCCESSING);
|
||||
|
||||
try
|
||||
{
|
||||
// TODO:
|
||||
// - Kiểm tra payload capacity
|
||||
// - Điều khiển loading mechanism
|
||||
// - Xác nhận hàng đã được tải
|
||||
|
||||
Logger.Info("Preparing loading...");
|
||||
await Task.Delay(200);
|
||||
|
||||
Logger.Info("Loading cargo...");
|
||||
await Task.Delay(1000);
|
||||
|
||||
Logger.Info("Loading completed");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.Error($"Loading error: {ex.Message}");
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
public void EntryLoading() => EntryLoadingAsync().GetAwaiter().GetResult();
|
||||
|
||||
/// <summary>
|
||||
/// Entry: ACT -> Unloading
|
||||
/// Đang dỡ hàng
|
||||
/// </summary>
|
||||
public async Task EntryUnloadingAsync()
|
||||
{
|
||||
Logger.Info("==> Entry Unloading State");
|
||||
PlcController.SetSystemState(SystemState.PROCCESSING);
|
||||
|
||||
try
|
||||
{
|
||||
// TODO:
|
||||
// - Kiểm tra vị trí unload
|
||||
// - Điều khiển unloading mechanism
|
||||
// - Xác nhận hàng đã được dỡ
|
||||
|
||||
Logger.Info("Preparing unloading...");
|
||||
await Task.Delay(200);
|
||||
|
||||
Logger.Info("Unloading cargo...");
|
||||
await Task.Delay(1000);
|
||||
|
||||
Logger.Info("Unloading completed");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.Error($"Unloading error: {ex.Message}");
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
public void EntryUnloading() => EntryUnloadingAsync().GetAwaiter().GetResult();
|
||||
|
||||
/// <summary>
|
||||
/// Entry: ACT -> TechAction
|
||||
/// Thực hiện các action kỹ thuật đặc biệt
|
||||
/// </summary>
|
||||
public async Task EntryTechActionAsync()
|
||||
{
|
||||
Logger.Info("==> Entry Tech Action State");
|
||||
PlcController.SetSystemState(SystemState.PROCCESSING);
|
||||
|
||||
try
|
||||
{
|
||||
// TODO:
|
||||
// - Xác định loại tech action
|
||||
// - Thực hiện action tương ứng
|
||||
// - Log kết quả
|
||||
|
||||
Logger.Info("Executing tech action...");
|
||||
await Task.Delay(500);
|
||||
|
||||
Logger.Info("Tech action completed");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.Error($"Tech action error: {ex.Message}");
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
public void EntryTechAction() => EntryTechActionAsync().GetAwaiter().GetResult();
|
||||
|
||||
// ===========================
|
||||
// MODE TRANSITION HANDLERS
|
||||
// ===========================
|
||||
|
||||
/// <summary>
|
||||
/// Entry: Manual Mode
|
||||
/// Chuyển sang chế độ thủ công
|
||||
/// </summary>
|
||||
public void EntryManual()
|
||||
{
|
||||
Logger.Info("==> Entry Manual Mode");
|
||||
PlcController.SetSystemState(SystemState.MANUAL);
|
||||
StopRobot();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Exit: Manual Mode
|
||||
/// </summary>
|
||||
public void ExitManual()
|
||||
{
|
||||
Logger.Info("<== Exit Manual Mode");
|
||||
|
||||
// TODO:
|
||||
// - Kiểm tra an toàn
|
||||
// - Tắt manual control
|
||||
// - Bật lại autonomous control
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Entry: Service Mode (MAINTENANCE)
|
||||
/// Chuyển sang chế độ bảo trì - cho phép điều khiển robot từ RF Handle
|
||||
/// </summary>
|
||||
public void EntryService()
|
||||
{
|
||||
Logger.Info("==> Entry Service Mode (MAINTENANCE)");
|
||||
PlcController.SetSystemState(SystemState.MAINTENANCE);
|
||||
StopRobot();
|
||||
|
||||
// Set ManualControlService state to Maintenance - cho phép điều khiển từ RF Handle
|
||||
try
|
||||
{
|
||||
using var scope = ServiceScopeFactory.CreateScope();
|
||||
var manualControlService = scope.ServiceProvider.GetRequiredService<ManualControlService>();
|
||||
manualControlService.SetState(ManualControlState.Maintenance);
|
||||
Logger.Info("ManualControlService set to Maintenance state");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.Error($"Error setting ManualControlService to Maintenance: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Exit: Service Mode (MAINTENANCE)
|
||||
/// </summary>
|
||||
public void ExitService()
|
||||
{
|
||||
Logger.Info("<== Exit Service Mode (MAINTENANCE)");
|
||||
|
||||
// Clear ManualControlService external state
|
||||
try
|
||||
{
|
||||
using var scope = ServiceScopeFactory.CreateScope();
|
||||
var manualControlService = scope.ServiceProvider.GetRequiredService<ManualControlService>();
|
||||
manualControlService.ClearExternalState();
|
||||
Logger.Info("ManualControlService external state cleared");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.Error($"Error clearing ManualControlService external state: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Entry: Stop State
|
||||
/// Robot bị dừng (Emergency, Bumper, etc.)
|
||||
/// </summary>
|
||||
public void EntryStop()
|
||||
{
|
||||
Logger.Warning("==> Entry STOP State");
|
||||
PlcController.SetSystemState(SystemState.PAUSED);
|
||||
EmergencyStop();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Exit: Stop State
|
||||
/// Giải phóng stop
|
||||
/// </summary>
|
||||
public void ExitStop()
|
||||
{
|
||||
Logger.Info("<== Exit Stop State");
|
||||
|
||||
// Giống Lock (SERVICE) → Auto/Manual: M815 pulse + fault reset + EnableAsync (RobotController.ApplyResetFromPlc).
|
||||
ApplyAlarmResetAndEnableMotorsLikeLock();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Entry: Fault State
|
||||
/// Robot gặp lỗi nghiêm trọng
|
||||
/// </summary>
|
||||
public void EntryFault()
|
||||
{
|
||||
Logger.Error("==> Entry FAULT State");
|
||||
PlcController.SetSystemState(SystemState.ERROR);
|
||||
EmergencyStop();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Exit: Fault State
|
||||
/// Đã khắc phục lỗi
|
||||
/// </summary>
|
||||
public void ExitFault()
|
||||
{
|
||||
Logger.Info("<== Exit Fault State");
|
||||
|
||||
// 1. Clear remaining fatal errors
|
||||
try
|
||||
{
|
||||
using var scope = ServiceScopeFactory.CreateScope();
|
||||
var errorManager = scope.ServiceProvider.GetRequiredService<IError>();
|
||||
errorManager.ClearFatalErrors();
|
||||
}
|
||||
catch (Exception ex) { Logger.Error($"ExitFault: Error clearing errors: {ex.Message}"); }
|
||||
|
||||
// 2. PLC alarm reset + servo: cùng chuỗi như thoát Lock / ApplyResetFromPlc
|
||||
ApplyAlarmResetAndEnableMotorsLikeLock();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Pulse M815 (alarm reset trên PLC), sau đó FaultReset + chờ + EnableAsync — khớp RobotController.ApplyResetFromPlc (không gọi TryClearFault).
|
||||
/// </summary>
|
||||
private void ApplyAlarmResetAndEnableMotorsLikeLock()
|
||||
{
|
||||
Logger.Info("ApplyAlarmResetAndEnableMotorsLikeLock: M815 + fault reset + enable (như Lock → Auto/Manual)");
|
||||
try { PlcController.WriteAlarmResetM815(); }
|
||||
catch (Exception ex) { Logger.Warning($"WriteAlarmResetM815: {ex.Message}"); }
|
||||
|
||||
try
|
||||
{
|
||||
using var scope = ServiceScopeFactory.CreateScope();
|
||||
var ik = scope.ServiceProvider.GetService<IInverseKinematics>();
|
||||
if (ik == null)
|
||||
{
|
||||
Logger.Warning("ApplyAlarmResetAndEnableMotorsLikeLock: IInverseKinematics không có");
|
||||
return;
|
||||
}
|
||||
|
||||
ik.FaultReset();
|
||||
Thread.Sleep(1500);
|
||||
ik.FaultReset();
|
||||
Thread.Sleep(800);
|
||||
ik.EnableAsync(CancellationToken.None).GetAwaiter().GetResult();
|
||||
if (ik.IsOperationEnabled)
|
||||
Logger.Info("ApplyAlarmResetAndEnableMotorsLikeLock: động cơ OperationEnabled");
|
||||
else
|
||||
Logger.Warning("ApplyAlarmResetAndEnableMotorsLikeLock: chưa OperationEnabled sau EnableAsync");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.Warning($"ApplyAlarmResetAndEnableMotorsLikeLock: FaultReset/Enable — {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
// ===========================
|
||||
// HELPER METHODS
|
||||
// ===========================
|
||||
|
||||
/// <summary>
|
||||
/// Dừng robot bình thường - gửi zero velocity đến IInverseKinematics
|
||||
/// </summary>
|
||||
private void StopRobot()
|
||||
{
|
||||
try
|
||||
{
|
||||
using var scope = ServiceScopeFactory.CreateScope();
|
||||
var inverseKinematics = scope.ServiceProvider.GetService<IInverseKinematics>();
|
||||
|
||||
if (inverseKinematics == null)
|
||||
{
|
||||
Logger.Warning("IInverseKinematics not available, cannot stop robot");
|
||||
return;
|
||||
}
|
||||
|
||||
// Send zero velocity to stop the robot
|
||||
// SetVelocity not available - commented out
|
||||
// var zeroTwist = new Twist();
|
||||
// inverseKinematics.SetVelocity(zeroTwist);
|
||||
|
||||
Logger.Info("Robot stopped (zero velocity intended)");
|
||||
}
|
||||
catch (AggregateException ex)
|
||||
{
|
||||
Logger.Error($"Stop robot error: {ex.InnerException?.Message ?? ex.Message}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.Error($"Stop robot error: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Dừng khẩn cấp robot - gửi zero velocity và disable IInverseKinematics
|
||||
/// </summary>
|
||||
private void EmergencyStop()
|
||||
{
|
||||
try
|
||||
{
|
||||
using var scope = ServiceScopeFactory.CreateScope();
|
||||
var inverseKinematics = scope.ServiceProvider.GetService<IInverseKinematics>();
|
||||
|
||||
if (inverseKinematics == null)
|
||||
{
|
||||
Logger.Warning("IInverseKinematics not available, cannot emergency stop robot");
|
||||
return;
|
||||
}
|
||||
|
||||
// Send zero velocity first
|
||||
// SetVelocity not available - commented out
|
||||
// var zeroTwist = new Twist();
|
||||
// inverseKinematics.SetVelocity(zeroTwist);
|
||||
|
||||
// Disable the drive (if supported)
|
||||
try
|
||||
{
|
||||
inverseKinematics.Disable();
|
||||
}
|
||||
catch (Exception disableEx)
|
||||
{
|
||||
Logger.Warning($"Could not disable IInverseKinematics: {disableEx.Message}");
|
||||
}
|
||||
|
||||
Logger.Warning("EMERGENCY STOP! Robot stopped and disabled");
|
||||
}
|
||||
catch (AggregateException ex)
|
||||
{
|
||||
Logger.Error($"Emergency stop error: {ex.InnerException?.Message ?? ex.Message}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.Error($"Emergency stop error: {ex.Message}");
|
||||
}
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user