Initial commit
This commit is contained in:
@@ -0,0 +1,386 @@
|
||||
using RobotNet10.CustomConfiguration.Models;
|
||||
using RobotNet10.CustomConfiguration.Services;
|
||||
|
||||
namespace RobotNet10.FleetManager.Services.ConfigManager;
|
||||
|
||||
/// <summary>
|
||||
/// Service implementation for managing ACSTraffic configurations
|
||||
/// </summary>
|
||||
public class ACSTrafficConfig : IACSTrafficConfig
|
||||
{
|
||||
private readonly IConfigManager _configManager;
|
||||
private readonly Logger<ACSTrafficConfig> _logger;
|
||||
|
||||
private readonly Lock _lockObject = new();
|
||||
private bool _configLoaded = false;
|
||||
|
||||
/// <summary>
|
||||
/// Event triggered when ACSTraffic configuration is changed/reloaded
|
||||
/// </summary>
|
||||
public event EventHandler? ConfigChanged;
|
||||
|
||||
private bool _trafficEnable = false;
|
||||
private int _trafficInterval = 1000;
|
||||
private string _trafficURL = string.Empty;
|
||||
private Dictionary<string, string> _acsZoneMaping = [];
|
||||
private Dictionary<string, string> _acsOutMaping = [];
|
||||
private bool _publishEnable = false;
|
||||
private string _publishURL = string.Empty;
|
||||
private int _publishInterval = 1000;
|
||||
|
||||
private const string ACS_TRAFFIC_CONFIG_TYPE = "ACSTrafficConfig";
|
||||
|
||||
public ACSTrafficConfig(IConfigManager configManager, Logger<ACSTrafficConfig> logger)
|
||||
{
|
||||
_configManager = configManager ?? throw new ArgumentNullException(nameof(configManager));
|
||||
_logger = logger;
|
||||
|
||||
// Subscribe to config changes
|
||||
_configManager.ConfigChanged += OnConfigChanged;
|
||||
}
|
||||
|
||||
public bool TrafficEnable
|
||||
{
|
||||
get
|
||||
{
|
||||
EnsureConfigLoaded();
|
||||
return _trafficEnable;
|
||||
}
|
||||
}
|
||||
|
||||
public int TrafficInterval
|
||||
{
|
||||
get
|
||||
{
|
||||
EnsureConfigLoaded();
|
||||
return _trafficInterval;
|
||||
}
|
||||
}
|
||||
|
||||
public string TrafficURL
|
||||
{
|
||||
get
|
||||
{
|
||||
EnsureConfigLoaded();
|
||||
return _trafficURL;
|
||||
}
|
||||
}
|
||||
|
||||
public Dictionary<string, string> ACSZoneMaping
|
||||
{
|
||||
get
|
||||
{
|
||||
EnsureConfigLoaded();
|
||||
return _acsZoneMaping;
|
||||
}
|
||||
}
|
||||
|
||||
public bool PublishEnable
|
||||
{
|
||||
get
|
||||
{
|
||||
EnsureConfigLoaded();
|
||||
return _publishEnable;
|
||||
}
|
||||
}
|
||||
|
||||
public string PublishURL
|
||||
{
|
||||
get
|
||||
{
|
||||
EnsureConfigLoaded();
|
||||
return _publishURL;
|
||||
}
|
||||
}
|
||||
|
||||
public int PublishInterval
|
||||
{
|
||||
get
|
||||
{
|
||||
EnsureConfigLoaded();
|
||||
return _publishInterval;
|
||||
}
|
||||
}
|
||||
|
||||
public Dictionary<string, string> ACSOutMaping
|
||||
{
|
||||
get
|
||||
{
|
||||
EnsureConfigLoaded();
|
||||
return _acsOutMaping;
|
||||
}
|
||||
}
|
||||
|
||||
private void EnsureConfigLoaded()
|
||||
{
|
||||
if (!_configLoaded)
|
||||
{
|
||||
lock (_lockObject)
|
||||
{
|
||||
if (!_configLoaded)
|
||||
{
|
||||
LoadACSTrafficConfigAsync().GetAwaiter().GetResult();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async Task LoadACSTrafficConfigAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
var configFile = await _configManager.GetConfigByTypeAsync(ACS_TRAFFIC_CONFIG_TYPE);
|
||||
|
||||
if (configFile == null)
|
||||
{
|
||||
_logger.Warning("ACSTraffic configuration not found, using defaults");
|
||||
_trafficEnable = false;
|
||||
_trafficInterval = 1000;
|
||||
_trafficURL = string.Empty;
|
||||
_acsZoneMaping = [];
|
||||
_acsOutMaping = [];
|
||||
_publishEnable = false;
|
||||
_publishURL = string.Empty;
|
||||
_publishInterval = 1000;
|
||||
}
|
||||
else
|
||||
{
|
||||
MapConfigVariablesToProperties(configFile.Variables);
|
||||
_logger.Info("ACSTraffic configuration loaded successfully");
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Error($"Error loading ACSTraffic configuration, using defaults: {ex.Message}");
|
||||
_trafficEnable = false;
|
||||
_trafficInterval = 1000;
|
||||
_trafficURL = string.Empty;
|
||||
_acsZoneMaping = [];
|
||||
_acsOutMaping = [];
|
||||
_publishEnable = false;
|
||||
_publishURL = string.Empty;
|
||||
_publishInterval = 1000;
|
||||
}
|
||||
finally
|
||||
{
|
||||
_configLoaded = true;
|
||||
// Trigger ConfigChanged event after config is loaded/reloaded
|
||||
OnConfigChanged();
|
||||
}
|
||||
}
|
||||
|
||||
private void OnConfigChanged(object? sender, RobotNet10.CustomConfiguration.Events.ConfigChangedEventArgs e)
|
||||
{
|
||||
// Reload config if our config type changed
|
||||
if (e.ConfigType == ACS_TRAFFIC_CONFIG_TYPE)
|
||||
{
|
||||
lock (_lockObject)
|
||||
{
|
||||
_configLoaded = false;
|
||||
}
|
||||
_logger.Info($"ACSTraffic configuration changed ({e.ConfigType}), will reload on next access");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Trigger ConfigChanged event to notify subscribers that config has been reloaded
|
||||
/// </summary>
|
||||
private void OnConfigChanged()
|
||||
{
|
||||
ConfigChanged?.Invoke(this, EventArgs.Empty);
|
||||
}
|
||||
|
||||
private void MapConfigVariablesToProperties(List<ConfigVariable> variables)
|
||||
{
|
||||
foreach (var variable in variables)
|
||||
{
|
||||
if (variable.Value == null)
|
||||
continue;
|
||||
|
||||
try
|
||||
{
|
||||
switch (variable.Name)
|
||||
{
|
||||
case nameof(TrafficEnable):
|
||||
_trafficEnable = (bool)ConvertValue(variable.Value, typeof(bool))!;
|
||||
break;
|
||||
case nameof(TrafficInterval):
|
||||
_trafficInterval = (int)ConvertValue(variable.Value, typeof(int))!;
|
||||
break;
|
||||
case nameof(TrafficURL):
|
||||
_trafficURL = (string)ConvertValue(variable.Value, typeof(string))!;
|
||||
break;
|
||||
case nameof(ACSZoneMaping):
|
||||
_acsZoneMaping = (Dictionary<string, string>)ConvertValue(variable.Value, typeof(Dictionary<string, string>))!;
|
||||
break;
|
||||
case nameof(ACSOutMaping):
|
||||
_acsOutMaping = (Dictionary<string, string>)ConvertValue(variable.Value, typeof(Dictionary<string, string>))!;
|
||||
break;
|
||||
case nameof(PublishEnable):
|
||||
_publishEnable = (bool)ConvertValue(variable.Value, typeof(bool))!;
|
||||
break;
|
||||
case nameof(PublishURL):
|
||||
_publishURL = (string)ConvertValue(variable.Value, typeof(string))!;
|
||||
break;
|
||||
case nameof(PublishInterval):
|
||||
_publishInterval = (int)ConvertValue(variable.Value, typeof(int))!;
|
||||
break;
|
||||
default:
|
||||
_logger.Warning($"Unknown variable name: {variable.Name}");
|
||||
break;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Warning($"Failed to set property {variable.Name} from variable: {ex.Message}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private object? ConvertValue(object? value, Type targetType)
|
||||
{
|
||||
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 if (underlyingType.IsGenericType && underlyingType.GetGenericTypeDefinition() == typeof(Dictionary<,>))
|
||||
{
|
||||
// Handle Dictionary types - try to parse from JSON string
|
||||
return ConvertDictionary(value, underlyingType);
|
||||
}
|
||||
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 type (supports Dictionary<string, string>)
|
||||
/// </summary>
|
||||
private object ConvertDictionary(object value, Type dictionaryType)
|
||||
{
|
||||
try
|
||||
{
|
||||
// Get key and value types
|
||||
var genericArgs = dictionaryType.GetGenericArguments();
|
||||
var keyType = genericArgs[0];
|
||||
var valueType = genericArgs[1];
|
||||
|
||||
// If value is already the correct Dictionary type, return it
|
||||
if (dictionaryType.IsInstanceOfType(value))
|
||||
{
|
||||
return value;
|
||||
}
|
||||
|
||||
// If value is Dictionary<string, object>, try to convert
|
||||
if (value is Dictionary<string, object> stringDict)
|
||||
{
|
||||
var result = Activator.CreateInstance(dictionaryType);
|
||||
var addMethod = dictionaryType.GetMethod("Add");
|
||||
|
||||
if (addMethod != null && result != null)
|
||||
{
|
||||
foreach (var kvp in stringDict)
|
||||
{
|
||||
var key = ConvertValue(kvp.Key, keyType);
|
||||
var val = ConvertValue(kvp.Value, valueType);
|
||||
addMethod.Invoke(result, [key, val]);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
// Try parse as JSON string
|
||||
var jsonString = value.ToString();
|
||||
if (!string.IsNullOrEmpty(jsonString))
|
||||
{
|
||||
try
|
||||
{
|
||||
var doc = System.Text.Json.JsonDocument.Parse(jsonString);
|
||||
if (doc.RootElement.ValueKind == System.Text.Json.JsonValueKind.Object)
|
||||
{
|
||||
var result = Activator.CreateInstance(dictionaryType);
|
||||
var addMethod = dictionaryType.GetMethod("Add");
|
||||
|
||||
if (addMethod != null && result != null)
|
||||
{
|
||||
foreach (var prop in doc.RootElement.EnumerateObject())
|
||||
{
|
||||
// Convert key
|
||||
var key = ConvertValue(prop.Name, keyType);
|
||||
|
||||
// Convert value
|
||||
var val = ConvertValue(prop.Value.GetString() ?? prop.Value.GetRawText(), valueType);
|
||||
|
||||
addMethod.Invoke(result, [key, val]);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Warning($"Failed to parse Dictionary from JSON string: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
// If all else fails, return default (empty dictionary)
|
||||
_logger.Warning($"Cannot convert {value.GetType()} to {dictionaryType}, using default (empty dictionary)");
|
||||
return Activator.CreateInstance(dictionaryType) ?? throw new InvalidOperationException($"Cannot create instance of {dictionaryType}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Warning($"Error converting Dictionary: {ex.Message}");
|
||||
// Return default (empty dictionary)
|
||||
return Activator.CreateInstance(dictionaryType) ?? throw new InvalidOperationException($"Cannot create instance of {dictionaryType}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,232 @@
|
||||
using System.Reflection;
|
||||
using RobotNet10.CustomConfiguration.Events;
|
||||
using RobotNet10.CustomConfiguration.Models;
|
||||
using RobotNet10.CustomConfiguration.Services;
|
||||
using RobotNet10.FleetManager.Services.RobotConnections.Models;
|
||||
using RobotNet10.MqttConnection;
|
||||
|
||||
namespace RobotNet10.FleetManager.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";
|
||||
|
||||
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)
|
||||
{
|
||||
_logger.Warning("VDA5050 Protocol configuration not found, using defaults");
|
||||
_vda5050Config = new VDA5050ProtocolConfig
|
||||
{
|
||||
Manufacturer = "RobotNet",
|
||||
Version = "2.1.0",
|
||||
TopicPrefix = "uagv/v2"
|
||||
};
|
||||
}
|
||||
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) ?? throw new InvalidOperationException($"MQTT configuration (ConfigType: {MQTT_CONFIG_TYPE}) not found");
|
||||
_mqttConfig = MapConfigVariablesToObject<MQTTConfig>(configFile.Variables);
|
||||
|
||||
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 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,52 @@
|
||||
namespace RobotNet10.FleetManager.Services.ConfigManager;
|
||||
|
||||
/// <summary>
|
||||
/// Service for managing ACSTraffic configurations
|
||||
/// </summary>
|
||||
public interface IACSTrafficConfig
|
||||
{
|
||||
/// <summary>
|
||||
/// Event triggered when ACSTraffic configuration is changed/reloaded
|
||||
/// </summary>
|
||||
event EventHandler? ConfigChanged;
|
||||
|
||||
/// <summary>
|
||||
/// Enable ACS Traffic control
|
||||
/// </summary>
|
||||
bool TrafficEnable { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Traffic interval time in milliseconds
|
||||
/// </summary>
|
||||
int TrafficInterval { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Traffic URL
|
||||
/// </summary>
|
||||
string TrafficURL { get; }
|
||||
|
||||
/// <summary>
|
||||
/// ACS Zone mapping dictionary
|
||||
/// </summary>
|
||||
Dictionary<string, string> ACSZoneMaping { get; }
|
||||
|
||||
/// <summary>
|
||||
/// ACS Out zone mapping with node
|
||||
/// </summary>
|
||||
Dictionary<string, string> ACSOutMaping { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Enable publish
|
||||
/// </summary>
|
||||
bool PublishEnable { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Publish URL
|
||||
/// </summary>
|
||||
string PublishURL { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Publish interval in milliseconds
|
||||
/// </summary>
|
||||
int PublishInterval { get; }
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
using RobotNet10.FleetManager.Services.RobotConnections.Models;
|
||||
using RobotNet10.MqttConnection;
|
||||
|
||||
namespace RobotNet10.FleetManager.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,14 @@
|
||||
using RobotNet10.FleetManager.Services.TrafficControl.Models;
|
||||
|
||||
namespace RobotNet10.FleetManager.Services.ConfigManager;
|
||||
|
||||
/// <summary>
|
||||
/// Service for managing TrafficControl configurations
|
||||
/// </summary>
|
||||
public interface ITrafficConfig
|
||||
{
|
||||
/// <summary>
|
||||
/// Get TrafficControl configuration
|
||||
/// </summary>
|
||||
TrafficControlConfig GetTrafficControlConfig();
|
||||
}
|
||||
@@ -0,0 +1,329 @@
|
||||
using System.Reflection;
|
||||
using RobotNet10.CustomConfiguration.Events;
|
||||
using RobotNet10.CustomConfiguration.Models;
|
||||
using RobotNet10.CustomConfiguration.Services;
|
||||
using RobotNet10.FleetManager.Services.TrafficControl.Models;
|
||||
|
||||
namespace RobotNet10.FleetManager.Services.ConfigManager;
|
||||
|
||||
/// <summary>
|
||||
/// Service implementation for managing TrafficControl configurations
|
||||
/// </summary>
|
||||
public class TrafficConfig : ITrafficConfig
|
||||
{
|
||||
private readonly IConfigManager _configManager;
|
||||
private readonly Logger<TrafficConfig> _logger;
|
||||
|
||||
private readonly Lock _lockObject = new();
|
||||
private TrafficControlConfig? _trafficControlConfig;
|
||||
private bool _configLoaded = false;
|
||||
|
||||
private const string CONFLICT_DETECTION_CONFIG_TYPE = "TrafficConflictDetectionConfig";
|
||||
private const string BASE_HORIZON_CONFIG_TYPE = "TrafficBaseHorizonConfig";
|
||||
private const string CONFLICT_RESOLUTION_CONFIG_TYPE = "TrafficConflictResolutionConfig";
|
||||
private const string PRIORITY_CONFIG_TYPE = "TrafficPriorityConfig";
|
||||
private const string PATH_PLANNING_CONFIG_TYPE = "TrafficPathPlanningConfig";
|
||||
|
||||
public TrafficConfig(IConfigManager configManager, Logger<TrafficConfig> logger)
|
||||
{
|
||||
_configManager = configManager ?? throw new ArgumentNullException(nameof(configManager));
|
||||
_logger = logger;
|
||||
|
||||
// Subscribe to config changes
|
||||
_configManager.ConfigChanged += OnConfigChanged;
|
||||
}
|
||||
|
||||
public TrafficControlConfig GetTrafficControlConfig()
|
||||
{
|
||||
if (!_configLoaded)
|
||||
{
|
||||
lock (_lockObject)
|
||||
{
|
||||
if (!_configLoaded)
|
||||
{
|
||||
LoadTrafficControlConfigAsync().GetAwaiter().GetResult();
|
||||
}
|
||||
}
|
||||
}
|
||||
return _trafficControlConfig ?? throw new InvalidOperationException("TrafficControl configuration not loaded");
|
||||
}
|
||||
|
||||
private async Task LoadTrafficControlConfigAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
// Load all nested configs
|
||||
var conflictDetectionConfig = await LoadNestedConfigAsync<ConflictDetectionConfig>(CONFLICT_DETECTION_CONFIG_TYPE);
|
||||
var baseHorizonConfig = await LoadNestedConfigAsync<BaseHorizonConfig>(BASE_HORIZON_CONFIG_TYPE);
|
||||
var conflictResolutionConfig = await LoadNestedConfigAsync<ConflictResolutionConfig>(CONFLICT_RESOLUTION_CONFIG_TYPE);
|
||||
var priorityConfig = await LoadNestedConfigAsync<PriorityConfig>(PRIORITY_CONFIG_TYPE);
|
||||
var pathPlanningConfig = await LoadNestedConfigAsync<PathPlanningConfig>(PATH_PLANNING_CONFIG_TYPE);
|
||||
|
||||
// Combine into TrafficControlConfig
|
||||
_trafficControlConfig = new TrafficControlConfig
|
||||
{
|
||||
ConflictDetection = conflictDetectionConfig,
|
||||
BaseHorizon = baseHorizonConfig,
|
||||
ConflictResolution = conflictResolutionConfig,
|
||||
Priority = priorityConfig,
|
||||
PathPlanning = pathPlanningConfig
|
||||
};
|
||||
|
||||
_configLoaded = true;
|
||||
_logger.Info("TrafficControl configuration loaded successfully");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Error($"Error loading TrafficControl configuration, using defaults: {ex.Message}");
|
||||
_trafficControlConfig = new TrafficControlConfig();
|
||||
_configLoaded = true;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<T> LoadNestedConfigAsync<T>(string configType) where T : new()
|
||||
{
|
||||
try
|
||||
{
|
||||
var configFile = await _configManager.GetConfigByTypeAsync(configType);
|
||||
|
||||
if (configFile == null)
|
||||
{
|
||||
_logger.Warning($"{configType} configuration not found, using defaults");
|
||||
return new T();
|
||||
}
|
||||
|
||||
var config = MapConfigVariablesToObject<T>(configFile.Variables);
|
||||
_logger.Info($"{configType} configuration loaded successfully");
|
||||
return config;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Warning($"Error loading {configType} configuration, using defaults: {ex.Message}");
|
||||
return new T();
|
||||
}
|
||||
}
|
||||
|
||||
private void OnConfigChanged(object? sender, ConfigChangedEventArgs e)
|
||||
{
|
||||
// Reload config if any of our config types changed
|
||||
if (e.ConfigType == CONFLICT_DETECTION_CONFIG_TYPE ||
|
||||
e.ConfigType == BASE_HORIZON_CONFIG_TYPE ||
|
||||
e.ConfigType == CONFLICT_RESOLUTION_CONFIG_TYPE ||
|
||||
e.ConfigType == PRIORITY_CONFIG_TYPE ||
|
||||
e.ConfigType == PATH_PLANNING_CONFIG_TYPE)
|
||||
{
|
||||
lock (_lockObject)
|
||||
{
|
||||
_configLoaded = false;
|
||||
_trafficControlConfig = null;
|
||||
}
|
||||
_logger.Info($"TrafficControl configuration changed ({e.ConfigType}), 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);
|
||||
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)
|
||||
{
|
||||
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 if (underlyingType.IsGenericType && underlyingType.GetGenericTypeDefinition() == typeof(Dictionary<,>))
|
||||
{
|
||||
// Handle Dictionary types - try to parse from JSON string
|
||||
return ConvertDictionary(value, underlyingType);
|
||||
}
|
||||
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 type (supports Dictionary<NavigationType, PathPlanningMethod>)
|
||||
/// </summary>
|
||||
private object ConvertDictionary(object value, Type dictionaryType)
|
||||
{
|
||||
try
|
||||
{
|
||||
// Get key and value types
|
||||
var genericArgs = dictionaryType.GetGenericArguments();
|
||||
var keyType = genericArgs[0];
|
||||
var valueType = genericArgs[1];
|
||||
|
||||
// If value is already the correct Dictionary type, return it
|
||||
if (dictionaryType.IsInstanceOfType(value))
|
||||
{
|
||||
return value;
|
||||
}
|
||||
|
||||
// If value is Dictionary<string, object>, try to convert
|
||||
if (value is Dictionary<string, object> stringDict)
|
||||
{
|
||||
var result = Activator.CreateInstance(dictionaryType);
|
||||
var addMethod = dictionaryType.GetMethod("Add");
|
||||
|
||||
if (addMethod != null && result != null)
|
||||
{
|
||||
foreach (var kvp in stringDict)
|
||||
{
|
||||
var key = ConvertValue(kvp.Key, keyType);
|
||||
var val = ConvertValue(kvp.Value, valueType);
|
||||
addMethod.Invoke(result, [key, val]);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
// Try parse as JSON string
|
||||
var jsonString = value.ToString();
|
||||
if (!string.IsNullOrEmpty(jsonString))
|
||||
{
|
||||
try
|
||||
{
|
||||
var doc = System.Text.Json.JsonDocument.Parse(jsonString);
|
||||
if (doc.RootElement.ValueKind == System.Text.Json.JsonValueKind.Object)
|
||||
{
|
||||
var result = Activator.CreateInstance(dictionaryType);
|
||||
var addMethod = dictionaryType.GetMethod("Add");
|
||||
|
||||
if (addMethod != null && result != null)
|
||||
{
|
||||
foreach (var prop in doc.RootElement.EnumerateObject())
|
||||
{
|
||||
// Convert key (e.g., "Differential" -> NavigationType.Differential)
|
||||
var key = ConvertEnumKey(prop.Name, keyType);
|
||||
|
||||
// Convert value (e.g., "Basic" -> PathPlanningMethod.Basic)
|
||||
var val = ConvertEnumValue(prop.Value.GetString() ?? prop.Value.GetRawText(), valueType);
|
||||
|
||||
addMethod.Invoke(result, [key, val]);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Warning($"Failed to parse Dictionary from JSON string: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
// If all else fails, return default (empty dictionary)
|
||||
_logger.Warning($"Cannot convert {value.GetType()} to {dictionaryType}, using default (empty dictionary)");
|
||||
return Activator.CreateInstance(dictionaryType) ?? throw new InvalidOperationException($"Cannot create instance of {dictionaryType}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Warning($"Error converting Dictionary: {ex.Message}");
|
||||
// Return default (empty dictionary)
|
||||
return Activator.CreateInstance(dictionaryType) ?? throw new InvalidOperationException($"Cannot create instance of {dictionaryType}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Convert string to enum key (e.g., "Differential" -> NavigationType.Differential)
|
||||
/// </summary>
|
||||
private static object ConvertEnumKey(string keyString, Type enumType)
|
||||
{
|
||||
if (enumType.IsEnum)
|
||||
{
|
||||
if (Enum.TryParse(enumType, keyString, true, out var enumValue))
|
||||
{
|
||||
return enumValue;
|
||||
}
|
||||
}
|
||||
// If not enum, try standard conversion
|
||||
return Convert.ChangeType(keyString, enumType);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Convert string to enum value (e.g., "Basic" -> PathPlanningMethod.Basic)
|
||||
/// </summary>
|
||||
private static object ConvertEnumValue(string valueString, Type enumType)
|
||||
{
|
||||
if (enumType.IsEnum)
|
||||
{
|
||||
if (Enum.TryParse(enumType, valueString, true, out var enumValue))
|
||||
{
|
||||
return enumValue;
|
||||
}
|
||||
}
|
||||
// If not enum, try standard conversion
|
||||
return Convert.ChangeType(valueString, enumType);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
namespace RobotNet10.FleetManager.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Interface for robot model image storage operations
|
||||
/// </summary>
|
||||
public interface IRobotModelImageStorageService
|
||||
{
|
||||
Task SaveImageAsync(Guid robotModelId, Stream imageStream, CancellationToken cancellationToken = default);
|
||||
Task<Stream?> GetImageAsync(Guid robotModelId, CancellationToken cancellationToken = default);
|
||||
Task<bool> DeleteImageAsync(Guid robotModelId, CancellationToken cancellationToken = default);
|
||||
Task<bool> ImageExistsAsync(Guid robotModelId, CancellationToken cancellationToken = default);
|
||||
Task<(int width, int height)> GetImageDimensionsAsync(Stream imageStream, CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
using RobotNet10.FleetManager.Shared.DTOs.RobotModel;
|
||||
using RobotNet10.MapManager.Data;
|
||||
|
||||
namespace RobotNet10.FleetManager.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Service for managing robot model map data based on VehicleType filtering
|
||||
/// </summary>
|
||||
public interface IRobotModelMapService
|
||||
{
|
||||
/// <summary>
|
||||
/// Get filtered nodes for a robot model based on VehicleType properties
|
||||
/// Returns only nodes that have NodeVehicleProperties for the robot model's VehicleType
|
||||
/// </summary>
|
||||
Task<List<Node>> GetFilteredNodesAsync(Guid robotModelId);
|
||||
|
||||
/// <summary>
|
||||
/// Get filtered edges for a robot model based on VehicleType properties
|
||||
/// Returns only edges that have EdgeVehicleProperties for the robot model's VehicleType
|
||||
/// </summary>
|
||||
Task<List<Edge>> GetFilteredEdgesAsync(Guid robotModelId);
|
||||
|
||||
/// <summary>
|
||||
/// Get filtered nodes for a robot model based on VehicleType properties and LevelId
|
||||
/// Returns only nodes that have NodeVehicleProperties for the robot model's VehicleType and belong to the specified level
|
||||
/// </summary>
|
||||
Task<List<Node>> GetFilteredNodesByLevelAsync(Guid robotModelId, Guid levelId);
|
||||
|
||||
/// <summary>
|
||||
/// Get filtered edges for a robot model based on VehicleType properties and LevelId
|
||||
/// Returns only edges that have EdgeVehicleProperties for the robot model's VehicleType and belong to the specified level
|
||||
/// </summary>
|
||||
Task<List<Edge>> GetFilteredEdgesByLevelAsync(Guid robotModelId, Guid levelId);
|
||||
|
||||
/// <summary>
|
||||
/// Get validated map data (nodes + edges) for a robot model
|
||||
/// Returns only valid nodes/edges after validation (nodes with edges, edges with both nodes)
|
||||
/// </summary>
|
||||
Task<RobotModelMapDataDto> GetValidatedMapDataAsync(Guid robotModelId);
|
||||
|
||||
/// <summary>
|
||||
/// Validate map data for a robot model
|
||||
/// Returns validation result with errors/warnings
|
||||
/// </summary>
|
||||
Task<MapValidationResultDto> ValidateMapForRobotModelAsync(Guid robotModelId);
|
||||
|
||||
/// <summary>
|
||||
/// Check if robot model has valid map configuration
|
||||
/// Returns true if VehicleTypeId and MapId are set and map data is valid
|
||||
/// </summary>
|
||||
Task<bool> HasValidMapConfigurationAsync(Guid robotModelId);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
using RobotNet10.FleetManager.Data;
|
||||
using RobotNet10.FleetManager.Shared.DTOs.RobotModel;
|
||||
|
||||
namespace RobotNet10.FleetManager.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Service for managing robot models
|
||||
/// </summary>
|
||||
public interface IRobotModelService
|
||||
{
|
||||
Task<RobotModel> CreateAsync(CreateRobotModelRequest request);
|
||||
Task<List<RobotModel>> GetAllAsync();
|
||||
Task<RobotModel?> GetByIdAsync(Guid id);
|
||||
Task<RobotModel> UpdateAsync(Guid id, UpdateRobotModelRequest request);
|
||||
Task<bool> DeleteAsync(Guid id);
|
||||
Task<bool> ExistsAsync(string modelName);
|
||||
Task<List<RobotModel>> SearchAsync(string query);
|
||||
Task<RobotModelUsageInfoDto> GetUsageInfoAsync(Guid id);
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
using RobotNet10.FleetManager.Data;
|
||||
using RobotNet10.FleetManager.Shared.DTOs.Robot;
|
||||
|
||||
namespace RobotNet10.FleetManager.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Service for managing robots
|
||||
/// </summary>
|
||||
public interface IRobotService
|
||||
{
|
||||
Task<Robot> CreateAsync(CreateRobotRequest request);
|
||||
Task<List<Robot>> GetAllAsync(Guid? modelId = null, Guid? mapId = null);
|
||||
Task<Robot?> GetByIdAsync(Guid id);
|
||||
Task<Robot?> GetByRobotIdAsync(string robotId);
|
||||
Task<Robot> UpdateAsync(Guid id, UpdateRobotRequest request);
|
||||
Task<bool> DeleteAsync(Guid id);
|
||||
Task<List<Robot>> GetByModelIdAsync(Guid modelId);
|
||||
Task<List<Robot>> GetByModelNameAsync(string modelName);
|
||||
Task<List<Robot>> SearchAsync(string query);
|
||||
Task<bool> ExistsAsync(string robotId);
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
namespace RobotNet10.FleetManager.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:
|
||||
if(Logger.IsEnabled(LogLevel.Trace))Logger.LogTrace("{mes}", message);
|
||||
break;
|
||||
case LogLevel.Debug:
|
||||
if (Logger.IsEnabled(LogLevel.Debug)) Logger.LogDebug("{mes}", message);
|
||||
break;
|
||||
case LogLevel.Information:
|
||||
if (Logger.IsEnabled(LogLevel.Information)) Logger.LogInformation("{mes}", message);
|
||||
break;
|
||||
case LogLevel.Warning:
|
||||
if (Logger.IsEnabled(LogLevel.Warning)) Logger.LogWarning("{mes}", message);
|
||||
break;
|
||||
case LogLevel.Error:
|
||||
if (Logger.IsEnabled(LogLevel.Error)) Logger.LogError("{mes}", message);
|
||||
break;
|
||||
case LogLevel.Critical:
|
||||
if (Logger.IsEnabled(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,16 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace RobotNet10.FleetManager.Services.OpenACS;
|
||||
|
||||
public class ACSHeader(string messageName, string time)
|
||||
{
|
||||
[JsonPropertyName("msgname")]
|
||||
[Required]
|
||||
public string MessageName { get; set; } = messageName;
|
||||
|
||||
[JsonPropertyName("time")]
|
||||
[Required]
|
||||
public string Time { get; set; } = time;
|
||||
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace RobotNet10.FleetManager.Services.OpenACS;
|
||||
|
||||
public class ACSPublishModel
|
||||
{
|
||||
[JsonPropertyName("header")]
|
||||
[Required]
|
||||
public ACSHeader Header { get; set; } = new("AGV_STATUS", DateTime.Today.ToString("yyyy-MM-dd HH:mm:ss.fff"));
|
||||
|
||||
[JsonPropertyName("body")]
|
||||
[Required]
|
||||
public RobotPublishStatusBody Body { get; set; } = new();
|
||||
}
|
||||
|
||||
public class RobotPublishStatusBody
|
||||
{
|
||||
[JsonPropertyName("agv_id")]
|
||||
[Required]
|
||||
public string Id { get; set; } = "";
|
||||
|
||||
[JsonPropertyName("state")]
|
||||
[Required]
|
||||
public string State { get; set; } = "-1";
|
||||
|
||||
[JsonPropertyName("site_code")]
|
||||
[Required]
|
||||
public string SiteCode { get; set; } = "";
|
||||
|
||||
[JsonPropertyName("area_code")]
|
||||
[Required]
|
||||
public string AreaCode { get; set; } = "";
|
||||
|
||||
[JsonPropertyName("area_name")]
|
||||
[Required]
|
||||
public string AreaName { get; set; } = "";
|
||||
|
||||
[JsonPropertyName("location")]
|
||||
[Required]
|
||||
public AGVLocation Location { get; set; } = new();
|
||||
|
||||
[JsonPropertyName("marker_id")]
|
||||
[Required]
|
||||
public string? MarkerId { get; set; }
|
||||
|
||||
[JsonPropertyName("battery_level")]
|
||||
[Required]
|
||||
public string BatteryLevel { get; set; } = "0";
|
||||
|
||||
[JsonPropertyName("battery_voltage")]
|
||||
[Required]
|
||||
public string BatteryVoltage { get; set; } = "0";
|
||||
|
||||
[JsonPropertyName("battery_current")]
|
||||
[Required]
|
||||
public string BatteryCurrent { get; set; } = "0";
|
||||
|
||||
[JsonPropertyName("battery_temperature")]
|
||||
[Required]
|
||||
public string BatteryTemprature { get; set; } = "0";
|
||||
|
||||
[JsonPropertyName("battery_id")]
|
||||
[Required]
|
||||
public string? BatteryId { get; set; }
|
||||
|
||||
[JsonPropertyName("battery_soh")]
|
||||
[Required]
|
||||
public string? BatterySOH { get; set; } = "0";
|
||||
|
||||
[JsonPropertyName("loading")]
|
||||
[Required]
|
||||
public string Loading { get; set; } = "0";
|
||||
|
||||
[JsonPropertyName("error_code")]
|
||||
[Required]
|
||||
public string? ErrorCode { get; set; }
|
||||
|
||||
[JsonPropertyName("station_id")]
|
||||
[Required]
|
||||
public string? StationId { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace RobotNet10.FleetManager.Services.OpenACS;
|
||||
|
||||
public class ACSStatusBodyResponse
|
||||
{
|
||||
[JsonPropertyName("result")]
|
||||
[Required]
|
||||
public string Result { get; set; } = string.Empty;
|
||||
|
||||
}
|
||||
|
||||
public class ACSStatusResponse
|
||||
{
|
||||
[JsonPropertyName("header")]
|
||||
[Required]
|
||||
public ACSHeader? Header { get; set; }
|
||||
|
||||
|
||||
[JsonPropertyName("body")]
|
||||
[Required]
|
||||
public ACSStatusBodyResponse? Body { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace RobotNet10.FleetManager.Services.OpenACS;
|
||||
|
||||
public class AGVLocation
|
||||
{
|
||||
[JsonPropertyName("world_x")]
|
||||
[Required]
|
||||
public string X { get; set; } = "0";
|
||||
|
||||
[JsonPropertyName("world_y")]
|
||||
[Required]
|
||||
public string Y { get; set; } = "0";
|
||||
|
||||
[JsonPropertyName("world_z")]
|
||||
[Required]
|
||||
public string Z { get; set; } = "0";
|
||||
|
||||
[JsonPropertyName("direction")]
|
||||
[Required]
|
||||
public string Direction { get; set; } = "0";
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
namespace RobotNet10.FleetManager.Services.OpenACS;
|
||||
|
||||
public enum AGVState
|
||||
{
|
||||
Offline = -1,
|
||||
Error = 0,
|
||||
Idle = 1,
|
||||
Processing = 2,
|
||||
Pause = 3,
|
||||
DockingFail = 4,
|
||||
NoPose = 5,
|
||||
Charging = 6,
|
||||
Run = 7,
|
||||
Stop = 8,
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
namespace RobotNet10.FleetManager.Services.OpenACS;
|
||||
|
||||
public class OpenACSException : Exception
|
||||
{
|
||||
public OpenACSException() { }
|
||||
public OpenACSException(string message) : base(message) { }
|
||||
public OpenACSException(string message, Exception innerException) : base(message, innerException) { }
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
using RobotNet.VDA5050.State;
|
||||
using RobotNet.VDA5050.Type;
|
||||
using RobotNet10.Common;
|
||||
using RobotNet10.FleetManager.Services.ConfigManager;
|
||||
using RobotNet10.FleetManager.Services.RobotManager;
|
||||
|
||||
namespace RobotNet10.FleetManager.Services.OpenACS;
|
||||
|
||||
public class OpenACSPublisher(IConfiguration configuration,
|
||||
Logger<OpenACSPublisher> Logger,
|
||||
ILogger<OpenACSPublisher> ILogger,
|
||||
IRobotManagerService RobotManager,
|
||||
IACSTrafficConfig ACSTrafficConfig) : BackgroundService
|
||||
{
|
||||
public int PublishCount { get; private set; }
|
||||
private WatchTimerAsync<OpenACSPublisher>? Timer;
|
||||
private readonly string ACSSiteCode = configuration["ACSStatusConfig:SiteCode"] ?? "VN03";
|
||||
private readonly string ACSAreaCode = configuration["ACSStatusConfig:AreaCode"] ?? "DA3_FL1";
|
||||
private readonly string ACSAreaName = configuration["ACSStatusConfig:AreaName"] ?? "DA3_WM";
|
||||
private readonly double ACSExtendX = configuration.GetValue<double>("ACSStatusConfig:ExtendX");
|
||||
private readonly double ACSExtendY = configuration.GetValue<double>("ACSStatusConfig:ExtendY");
|
||||
private readonly double ACSExtendTheta = configuration.GetValue<double>("ACSStatusConfig:ExtendTheta");
|
||||
|
||||
private readonly SemaphoreSlim _timerLock = new(1, 1);
|
||||
private async Task TimerHandler()
|
||||
{
|
||||
if (ACSTrafficConfig.PublishEnable && !string.IsNullOrEmpty(ACSTrafficConfig.PublishURL))
|
||||
{
|
||||
try
|
||||
{
|
||||
using var HttpClient = new HttpClient() { Timeout = TimeSpan.FromSeconds(15) };
|
||||
var robotControllers = RobotManager.GetAllRobotControllers();
|
||||
foreach (var robot in robotControllers)
|
||||
{
|
||||
var startTime = DateTime.Now;
|
||||
if (robot.Value.Data.State == null || robot.Value.Data.State.AgvPosition == null) continue;
|
||||
if ((startTime - robot.Value.Data.State.Timestamp).TotalMilliseconds > ACSTrafficConfig.PublishInterval) continue;
|
||||
|
||||
int batLevel = (int)robot.Value.Data.State.BatteryState.BatteryHealth;
|
||||
if (batLevel <= 0) batLevel = 85;
|
||||
|
||||
int batVol = (int)(robot.Value.Data.State.BatteryState.BatteryVoltage ?? 0);
|
||||
if (batVol <= 0) batVol = 24;
|
||||
var status = new ACSPublishModel()
|
||||
{
|
||||
Header = new("AGV_STATUS", robot.Value.Data.State.Timestamp.ToString("yyyy-MM-dd HH:mm:ss.fff")),
|
||||
Body = new()
|
||||
{
|
||||
Id = robot.Key,
|
||||
Location = new()
|
||||
{
|
||||
X = (robot.Value.Data.State.AgvPosition.X + ACSExtendX).ToString(),
|
||||
Y = (robot.Value.Data.State.AgvPosition.Y + ACSExtendY).ToString(),
|
||||
Z = "0",
|
||||
Direction = (robot.Value.Data.State.AgvPosition.Theta + ACSExtendTheta).ToString(),
|
||||
},
|
||||
SiteCode = ACSSiteCode,
|
||||
AreaCode = ACSAreaCode,
|
||||
AreaName = ACSAreaName,
|
||||
MarkerId = string.IsNullOrEmpty(robot.Value.Data.State.LastNodeId) ? null : robot.Value.Data.State.LastNodeId,
|
||||
BatteryId = null,
|
||||
BatteryLevel = batLevel.ToString(),
|
||||
BatteryVoltage = batVol.ToString(),
|
||||
BatterySOH = null,
|
||||
BatteryCurrent = "1.0",
|
||||
BatteryTemprature = "30",
|
||||
StationId = null,
|
||||
Loading = robot.Value.Data.State.Loads.Length != 0 ? "1" : "0",
|
||||
ErrorCode = GetErrorCode(robot.Value.Data.State.Errors ?? [])?.ToString() ?? null,
|
||||
State = GetStatus(robot.Value.Data.State).ToString(),
|
||||
}
|
||||
};
|
||||
|
||||
var response = await HttpClient.PostAsJsonAsync(ACSTrafficConfig.PublishURL, status);
|
||||
if (response.IsSuccessStatusCode)
|
||||
{
|
||||
var result = await response.Content.ReadFromJsonAsync<ACSStatusResponse>();
|
||||
if (result == null)
|
||||
{
|
||||
Logger.Error("Failed to convert response.Content to ACSStatusResponse");
|
||||
}
|
||||
else if (result.Header?.MessageName == "AGV_STATUS_ACK" && result.Body?.Result == "OK")
|
||||
{
|
||||
PublishCount++;
|
||||
}
|
||||
else
|
||||
{
|
||||
Logger.Warning($"ACS response is not OK: {System.Text.Json.JsonSerializer.Serialize(result)}");
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
Logger.Warning($"ACS publish to {ACSTrafficConfig.PublishURL} failed: {response.StatusCode}");
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.Warning($"ACS publish to {ACSTrafficConfig.PublishURL} error: {ex.Message}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static int GetStatus(StateMsg state)
|
||||
{
|
||||
if (GetError(state) == ErrorLevel.FATAL || GetError(state) == ErrorLevel.WARNING) return (int)AGVState.Error;
|
||||
else if (state.BatteryState.Charging) return (int)AGVState.Charging;
|
||||
else if (state.Paused) return (int)AGVState.Pause;
|
||||
else if (IsIdle(state)) return (int)AGVState.Idle;
|
||||
else if (IsWorking(state)) return (int)AGVState.Run;
|
||||
else return (int)AGVState.Stop;
|
||||
}
|
||||
|
||||
private static string? GetErrorCode(Error[] errors)
|
||||
{
|
||||
var error = errors.FirstOrDefault();
|
||||
if (error is not null && int.TryParse(error.ErrorType, out int errorCode)) return errorCode.ToString();
|
||||
return null;
|
||||
}
|
||||
|
||||
private static bool IsIdle(StateMsg state)
|
||||
{
|
||||
if (state.NodeStates.Length != 0 || state.EdgeStates.Length != 0) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
private static bool IsWorking(StateMsg state)
|
||||
{
|
||||
if (state.NodeStates.Length != 0 || state.EdgeStates.Length != 0) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
private static ErrorLevel GetError(StateMsg state)
|
||||
{
|
||||
if (state.Errors is not null)
|
||||
{
|
||||
if (state.Errors.Any(error => error.ErrorLevel == ErrorLevel.FATAL)) return ErrorLevel.FATAL;
|
||||
if (state.Errors.Any(error => error.ErrorLevel == ErrorLevel.WARNING)) return ErrorLevel.WARNING;
|
||||
}
|
||||
return ErrorLevel.NONE;
|
||||
}
|
||||
|
||||
private async Task InitializeTimerAsync()
|
||||
{
|
||||
if (ACSTrafficConfig.PublishInterval == Timer?.Interval) return;
|
||||
if (_timerLock.Wait(1000))
|
||||
{
|
||||
try
|
||||
{
|
||||
Timer?.Dispose();
|
||||
Timer = new(ACSTrafficConfig.PublishInterval, TimerHandler, ILogger);
|
||||
Timer.Start();
|
||||
}
|
||||
finally
|
||||
{
|
||||
_timerLock.Release();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
await Task.Yield();
|
||||
while (!stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
try
|
||||
{
|
||||
ACSTrafficConfig.ConfigChanged += ConfigChanged;
|
||||
await InitializeTimerAsync();
|
||||
break;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.Warning($"ACS Publisher: Initialization error: {ex.Message}");
|
||||
await Task.Delay(2000, stoppingToken);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public async void ConfigChanged(object? sender, EventArgs e)
|
||||
{
|
||||
await InitializeTimerAsync();
|
||||
}
|
||||
|
||||
public override Task StopAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
ACSTrafficConfig.ConfigChanged -= ConfigChanged;
|
||||
Timer?.Dispose();
|
||||
_timerLock?.Dispose();
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
using RobotNet10.FleetManager.Services.ConfigManager;
|
||||
using RobotNet10.Shared;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace RobotNet10.FleetManager.Services.OpenACS;
|
||||
|
||||
public class TrafficACS(IACSTrafficConfig OpenACSManager, Logger<TrafficACS> Logger)
|
||||
{
|
||||
private static readonly JsonSerializerOptions jsonSerializeOptions = new() {
|
||||
WriteIndented = true,
|
||||
Encoder = System.Text.Encodings.Web.JavaScriptEncoder.UnsafeRelaxedJsonEscaping
|
||||
};
|
||||
|
||||
public async Task<MessageResult<bool>> RequestIn(string robotId, string zoneId)
|
||||
{
|
||||
var model = new TrafficACSRequest(robotId, zoneId, TrafficRequestType.IN);
|
||||
TrafficACSResponse? response = null;
|
||||
HttpResponseMessage? responseStr = null;
|
||||
try
|
||||
{
|
||||
if (!OpenACSManager.TrafficEnable) return new(true, true, "Kết nối với hệ thống traffic ACS không được bật");
|
||||
using var HttpClient = new HttpClient() { Timeout = TimeSpan.FromSeconds(15) };
|
||||
|
||||
responseStr = await HttpClient.PostAsJsonAsync(OpenACSManager.TrafficURL, model);
|
||||
response = await responseStr.Content.ReadFromJsonAsync<TrafficACSResponse>() ?? throw new OpenACSException("Lỗi giao tiếp với hệ thống traffic ACS");
|
||||
|
||||
if (response.AgvId != robotId) throw new OpenACSException($"Dữ liệu hệ thống traffic ACS agv_id trả về {response.AgvId} không trùng với dữ liệu gửi đi {robotId}");
|
||||
if (response.TrafficZoneId != zoneId) throw new OpenACSException($"Dữ liệu hệ thống traffic ACS traffic_zone_id trả về {response.TrafficZoneId} không trùng với dữ liệu gửi đi {zoneId}");
|
||||
if (response.InOut != TrafficRequestType.IN) throw new OpenACSException($"Dữ liệu hệ thống traffic ACS inout trả về {response.InOut} không trùng với dữ liệu gửi đi in");
|
||||
if (response.Result != TrafficACSResult.GO) throw new OpenACSException($"Dữ liệu hệ thống traffic ACS result trả về {response.Result} - không cho phép đi vào vùng {zoneId}");
|
||||
|
||||
Logger.Info($"{robotId} request into traffic zone {zoneId} succeeded");
|
||||
return new(true, true, "Request into traffic zone succeeded");
|
||||
}
|
||||
catch (OpenACSException ex)
|
||||
{
|
||||
Logger.Warning($"{robotId} request in error: {ex.Message}. \nRequest: {JsonSerializer.Serialize(model, jsonSerializeOptions)}\n Response: {(response is null ? "" : JsonSerializer.Serialize(response, jsonSerializeOptions))}, Raw: {(responseStr is null ? "" : await responseStr.Content.ReadAsStringAsync())}");
|
||||
return new(false, false, ex.Message);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.Warning($"{robotId} request In error: {ex.Message} \nRaw: {(responseStr is null ? "" : await responseStr.Content.ReadAsStringAsync())}");
|
||||
return new(false, false, "Traffic ACS communication error");
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<MessageResult<bool>> RequestOut(string robotId, string zoneId)
|
||||
{
|
||||
var model = new TrafficACSRequest(robotId, zoneId, TrafficRequestType.OUT);
|
||||
TrafficACSResponse? response = null;
|
||||
try
|
||||
{
|
||||
if (!OpenACSManager.TrafficEnable) return new(true, true, "Kết nối với hệ thống traffic ACS không được bật");
|
||||
using var HttpClient = new HttpClient() { Timeout = TimeSpan.FromSeconds(15) };
|
||||
|
||||
response = await (await HttpClient.PostAsJsonAsync(OpenACSManager.TrafficURL, model)).Content.ReadFromJsonAsync<TrafficACSResponse>() ??
|
||||
throw new OpenACSException("Lỗi giao tiếp với hệ thống traffic ACS");
|
||||
|
||||
if (response.AgvId != robotId) throw new OpenACSException($"Dữ liệu hệ thống traffic ACS agv_id trả về {response.AgvId} không trùng với dữ liệu gửi đi {robotId}");
|
||||
if (response.TrafficZoneId != zoneId) throw new OpenACSException($"Dữ liệu hệ thống traffic ACS traffic_zone_id trả về {response.TrafficZoneId} không trùng với dữ liệu gửi đi {zoneId}");
|
||||
if (response.InOut != TrafficRequestType.OUT) throw new OpenACSException($"Dữ liệu hệ thống traffic ACS inout trả về {response.InOut} không trùng với dữ liệu gửi đi out");
|
||||
if (response.Result != TrafficACSResult.GO) throw new OpenACSException($"Dữ liệu hệ thống traffic ACS result trả về {response.Result} - không cho phép xóa bỏ vùng {zoneId}");
|
||||
|
||||
Logger.Info($"{robotId} request out of traffic zone {zoneId} succeeded");
|
||||
return new(true, true, "Request out of traffic zone succeeded");
|
||||
}
|
||||
catch (OpenACSException ex)
|
||||
{
|
||||
Logger.Warning($"{robotId} request out error: {ex.Message}. \nRequest: {JsonSerializer.Serialize(model, jsonSerializeOptions)}\n Response: {(response is null ? "" : JsonSerializer.Serialize(response, jsonSerializeOptions))}");
|
||||
return new(false, false, ex.Message);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.Warning($"{robotId} request Out error: {ex.Message}");
|
||||
return new(false, false, "Traffic ACS communication error");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace RobotNet10.FleetManager.Services.OpenACS;
|
||||
|
||||
public class TrafficRequestType
|
||||
{
|
||||
public static string IN => "in";
|
||||
public static string OUT => "out";
|
||||
}
|
||||
|
||||
public class TrafficACSRequestBody(string agvId, string trafficZoneId, string inOut)
|
||||
{
|
||||
[JsonPropertyName("agvid")]
|
||||
[Required]
|
||||
public string AgvId { get; set; } = agvId;
|
||||
|
||||
[JsonPropertyName("area")]
|
||||
[Required]
|
||||
public string TrafficZoneId { get; set; } = trafficZoneId;
|
||||
|
||||
[JsonPropertyName("inout")]
|
||||
[Required]
|
||||
public string InOut { get; set; } = inOut;
|
||||
}
|
||||
|
||||
public class TrafficACSRequest
|
||||
{
|
||||
[JsonPropertyName("header")]
|
||||
[Required]
|
||||
public ACSHeader Header { get; set; } = new("TRAFFIC_REQ", DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss.fff"));
|
||||
|
||||
[JsonPropertyName("body")]
|
||||
[Required]
|
||||
public TrafficACSRequestBody Body { get; set; }
|
||||
|
||||
public TrafficACSRequest(string agvId, string trafficZoneId, string inOut)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(agvId))
|
||||
throw new ArgumentException("AGV ID không thể rỗng.", nameof(agvId));
|
||||
if (string.IsNullOrWhiteSpace(trafficZoneId))
|
||||
throw new ArgumentException("Traffic Zone ID không thể rỗng.", nameof(trafficZoneId));
|
||||
if (string.IsNullOrWhiteSpace(inOut))
|
||||
throw new ArgumentException("In OUT không thể rỗng.", nameof(inOut));
|
||||
|
||||
Body = new(agvId, trafficZoneId, inOut);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace RobotNet10.FleetManager.Services.OpenACS;
|
||||
public class TrafficACSResult
|
||||
{
|
||||
public static string GO => "go";
|
||||
public static string NO => "no";
|
||||
}
|
||||
public class TrafficACSResponse
|
||||
{
|
||||
[JsonPropertyName("time")]
|
||||
[Required]
|
||||
public string? Time { get; set; }
|
||||
|
||||
[JsonPropertyName("agv_id")]
|
||||
[Required]
|
||||
public string? AgvId { get; set; }
|
||||
|
||||
[JsonPropertyName("traffic_zone_id")]
|
||||
[Required]
|
||||
public string? TrafficZoneId { get; set; }
|
||||
|
||||
[JsonPropertyName("inout")]
|
||||
[Required]
|
||||
public string? InOut { get; set; }
|
||||
|
||||
[JsonPropertyName("result")]
|
||||
[Required]
|
||||
public string? Result { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
using RobotNet.VDA5050.InstantAction;
|
||||
using RobotNet.VDA5050.Order;
|
||||
|
||||
namespace RobotNet10.FleetManager.Services.RobotConnections;
|
||||
|
||||
/// <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 to a robot
|
||||
/// </summary>
|
||||
Task<bool> PublishOrderAsync(string robotId, OrderMsg order, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Publish instant actions message to a robot
|
||||
/// </summary>
|
||||
Task<bool> PublishInstantActionsAsync(string robotId, InstantActionsMsg instantActions, CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
namespace RobotNet10.FleetManager.Services.RobotConnections.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;
|
||||
}
|
||||
@@ -0,0 +1,329 @@
|
||||
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.Visualization;
|
||||
using RobotNet10.FleetManager.Events;
|
||||
using RobotNet10.FleetManager.Services.ConfigManager;
|
||||
using RobotNet10.MqttConnection;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace RobotNet10.FleetManager.Services.RobotConnections;
|
||||
|
||||
/// <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)
|
||||
{
|
||||
try
|
||||
{
|
||||
await StopAsync();
|
||||
|
||||
if (!_connectionSemaphore.Wait(1000)) return;
|
||||
var mqttConfig = _configManager.GetMqttConfig();
|
||||
var vdaConfig = _configManager.GetVDA5050Config();
|
||||
|
||||
MqttTopicFilter[] topics = [
|
||||
new MqttTopicFilterBuilder().WithTopic($"{vdaConfig.TopicPrefix}/{vdaConfig.Manufacturer}/+/{VDA5050Topic.STATE.ToJsonString()}")
|
||||
.WithAtMostOnceQoS()
|
||||
.Build(),
|
||||
new MqttTopicFilterBuilder().WithTopic($"{vdaConfig.TopicPrefix}/{vdaConfig.Manufacturer}/+/{VDA5050Topic.VISUALIZATION.ToJsonString()}")
|
||||
.WithAtMostOnceQoS()
|
||||
.Build(),
|
||||
new MqttTopicFilterBuilder().WithTopic($"{vdaConfig.TopicPrefix}/{vdaConfig.Manufacturer}/+/{VDA5050Topic.FACTSHEET.ToJsonString()}")
|
||||
.WithAtMostOnceQoS()
|
||||
.Build(),
|
||||
new MqttTopicFilterBuilder().WithTopic($"{vdaConfig.TopicPrefix}/{vdaConfig.Manufacturer}/+/{VDA5050Topic.CONNECTION.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);
|
||||
|
||||
_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))
|
||||
{
|
||||
using var scope = _serviceProvider.CreateAsyncScope();
|
||||
var _robotService = scope.ServiceProvider.GetRequiredService<IRobotService>();
|
||||
if (await _robotService.ExistsAsync(robotId))
|
||||
{
|
||||
if (messageType == VDA5050Topic.STATE.ToJsonString())
|
||||
{
|
||||
HandleStateMessageAsync(robotId, payload);
|
||||
}
|
||||
else if (messageType == VDA5050Topic.VISUALIZATION.ToJsonString())
|
||||
{
|
||||
HandleVisualizationMessageAsync(robotId, payload);
|
||||
}
|
||||
else if (messageType == VDA5050Topic.FACTSHEET.ToJsonString())
|
||||
{
|
||||
HandleFactsheetMessageAsync(robotId, payload);
|
||||
}
|
||||
else if (messageType == VDA5050Topic.CONNECTION.ToJsonString())
|
||||
{
|
||||
HandleConnectionMessageAsync(robotId, 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);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<bool> PublishOrderAsync(string robotId, OrderMsg order, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (_mqttClient is null)
|
||||
{
|
||||
_logger.Warning("Mqtt Client not initialized");
|
||||
return false;
|
||||
}
|
||||
|
||||
if (string.IsNullOrEmpty(robotId))
|
||||
{
|
||||
_logger.Warning("Cannot publish order: robotId is null or empty");
|
||||
return false;
|
||||
}
|
||||
|
||||
if (order == null)
|
||||
{
|
||||
_logger.Warning("Cannot publish order: order message is null");
|
||||
return false;
|
||||
}
|
||||
|
||||
if (string.IsNullOrEmpty(order.OrderId))
|
||||
{
|
||||
_logger.Warning("Cannot publish order: orderId is null or empty");
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!IsConnected)
|
||||
{
|
||||
_logger.Warning($"Cannot publish order to robot {robotId}: MQTT client is not connected");
|
||||
return false;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var topic = BuildPublishTopic(robotId, VDA5050Topic.ORDER);
|
||||
var data = JsonSerializer.Serialize(order, JsonOptionExtends.Write);
|
||||
await _mqttClient.PublishAsync(topic, data);
|
||||
return true;
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
_logger.Warning($"Publish order to robot {robotId} was cancelled");
|
||||
return false;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Error($"Error publishing order to robot {robotId}: {ex.Message}");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<bool> PublishInstantActionsAsync(string robotId, InstantActionsMsg instantActions, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (_mqttClient is null)
|
||||
{
|
||||
_logger.Warning("Mqtt Client not initialized");
|
||||
return false;
|
||||
}
|
||||
|
||||
if (string.IsNullOrEmpty(robotId))
|
||||
{
|
||||
_logger.Warning("Cannot publish instantActions: robotId is null or empty");
|
||||
return false;
|
||||
}
|
||||
|
||||
if (instantActions == null)
|
||||
{
|
||||
_logger.Warning("Cannot publish instantActions: instantActions message is null");
|
||||
return false;
|
||||
}
|
||||
|
||||
if (instantActions.Actions == null || instantActions.Actions.Length == 0)
|
||||
{
|
||||
_logger.Warning("Cannot publish instantActions: actions array is null or empty");
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!IsConnected)
|
||||
{
|
||||
_logger.Warning($"Cannot publish instantActions to robot {robotId}: MQTT client is not connected");
|
||||
return false;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var topic = BuildPublishTopic(robotId, VDA5050Topic.INSTANTACTIONS);
|
||||
var data = JsonSerializer.Serialize(instantActions, JsonOptionExtends.Write);
|
||||
await _mqttClient.PublishAsync(topic, data);
|
||||
return true;
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
_logger.Warning($"Publish instantActions to robot {robotId} was cancelled");
|
||||
return false;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Error($"Error publishing instantActions to robot {robotId}: {ex.Message}");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private void HandleStateMessageAsync(string serialNumber, string payload)
|
||||
{
|
||||
try
|
||||
{
|
||||
var stateMsg = JsonSerializer.Deserialize<StateMsg>(payload, JsonOptionExtends.Read);
|
||||
if (stateMsg == null || stateMsg.SerialNumber != serialNumber) return;
|
||||
|
||||
_eventBus.PublishStateMessageReceived(serialNumber, stateMsg);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Error($"Error handling state message from robot {serialNumber}: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private void HandleConnectionMessageAsync(string serialNumber, string payload)
|
||||
{
|
||||
try
|
||||
{
|
||||
var connectionMsg = JsonSerializer.Deserialize<ConnectionMsg>(payload, JsonOptionExtends.Read);
|
||||
if (connectionMsg == null || connectionMsg.SerialNumber != serialNumber) return;
|
||||
|
||||
_eventBus.PublishConnectionStateChanged(serialNumber, connectionMsg.ConnectionState);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Error($"Error handling connection message from robot {serialNumber}: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private void HandleVisualizationMessageAsync(string serialNumber, string payload)
|
||||
{
|
||||
try
|
||||
{
|
||||
var visualizationMsg = JsonSerializer.Deserialize<VisualizationMsg>(payload, JsonOptionExtends.Read);
|
||||
if (visualizationMsg == null || visualizationMsg.SerialNumber != serialNumber) return;
|
||||
|
||||
_eventBus.PublishVisualizationMessageReceived(serialNumber, visualizationMsg);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Error($"Error handling visualization message from robot {serialNumber}: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private void HandleFactsheetMessageAsync(string serialNumber, string payload)
|
||||
{
|
||||
try
|
||||
{
|
||||
var factsheetMsg = JsonSerializer.Deserialize<FactSheetMsg>(payload, JsonOptionExtends.Read);
|
||||
if (factsheetMsg == null || factsheetMsg.SerialNumber != serialNumber) return;
|
||||
|
||||
_eventBus.PublishFactsheetMessageReceived(serialNumber, factsheetMsg);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Error($"Error handling factsheet message from robot {serialNumber}: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private string BuildPublishTopic(string robotId, VDA5050Topic topic)
|
||||
{
|
||||
var vdaConfig = _configManager.GetVDA5050Config();
|
||||
return $"{vdaConfig.TopicPrefix}/{vdaConfig.Manufacturer}/{robotId}/{topic.ToJsonString()}";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
using RobotNet.VDA5050.InstantAction;
|
||||
using RobotNet.VDA5050.Order;
|
||||
using RobotNet10.FleetManager.Script;
|
||||
using RobotNet10.FleetManager.Services.RobotManager.Models;
|
||||
using RobotNet10.Shared;
|
||||
using Action = RobotNet.VDA5050.InstantAction.Action;
|
||||
|
||||
namespace RobotNet10.FleetManager.Services.RobotController;
|
||||
|
||||
/// <summary>
|
||||
/// Interface for RobotController - instance per robot
|
||||
/// </summary>
|
||||
public interface IRobotController : IDisposable
|
||||
{
|
||||
/// <summary>
|
||||
/// Robot ID (SerialNumber)
|
||||
/// </summary>
|
||||
string RobotId { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Robot data containing all robot information
|
||||
/// </summary>
|
||||
RobotData Data { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether robot is online (derived from ConnectionState)
|
||||
/// </summary>
|
||||
bool IsOnline { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether the robot is ready for the order
|
||||
/// </summary>
|
||||
bool IsReady { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Move robot to a specific node by NodeName
|
||||
/// </summary>
|
||||
Task<MessageResult> MoveToNodeAsync(string nodeName, double? angle, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Move robot to a specific node by NodeId (string)
|
||||
/// </summary>
|
||||
Task<MessageResult> MoveToNodeByIdAsync(string nodeId, double? angle, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Move robot to a specific node by NodeId (Guid)
|
||||
/// </summary>
|
||||
Task<MessageResult> MoveToNodeByGuidAsync(Guid nodeId, double? angle, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Move the robot to a taget action and execute action
|
||||
/// </summary>
|
||||
/// <param name="nodeName"></param>
|
||||
/// <param name="action"></param>
|
||||
/// <param name="cancellationToken"></param>
|
||||
/// <returns></returns>
|
||||
Task<MessageResult> MoveToStationAsync(string nodeName, StationAction action, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Send a single instant action to robot
|
||||
/// </summary>
|
||||
Task<MessageResult> SendInstantActionAsync(Action action, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Send order to robot
|
||||
/// </summary>
|
||||
Task<MessageResult> SendOrderAsync(OrderMsg order, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Send instant actions message to robot
|
||||
/// </summary>
|
||||
Task<MessageResult> SendInstantActionsAsync(InstantActionsMsg instantActions, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Request factsheet from robot
|
||||
/// </summary>
|
||||
Task<MessageResult> RequestFactsheetAsync(CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Request state from robot
|
||||
/// </summary>
|
||||
Task<MessageResult> RequestStateAsync(CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Cancel current order
|
||||
/// </summary>
|
||||
Task<MessageResult> CancelOrderAsync(CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,612 @@
|
||||
using RobotNet.VDA5050;
|
||||
using RobotNet.VDA5050.InstantAction;
|
||||
using RobotNet.VDA5050.Order;
|
||||
using RobotNet.VDA5050.Type;
|
||||
using RobotNet10.FleetManager.Data;
|
||||
using RobotNet10.FleetManager.Script;
|
||||
using RobotNet10.FleetManager.Services.ConfigManager;
|
||||
using RobotNet10.FleetManager.Services.RobotConnections;
|
||||
using RobotNet10.FleetManager.Services.RobotManager.Models;
|
||||
using RobotNet10.FleetManager.Services.TrafficControl;
|
||||
using RobotNet10.FleetManager.Services.TrafficControl.Models;
|
||||
using RobotNet10.MapManager.Services;
|
||||
using RobotNet10.Shared;
|
||||
using Action = RobotNet.VDA5050.InstantAction.Action;
|
||||
|
||||
namespace RobotNet10.FleetManager.Services.RobotController;
|
||||
|
||||
/// <summary>
|
||||
/// RobotController - instance per robot
|
||||
/// Mô hình hóa thông tin của 1 robot, định danh bằng RobotId (SerialNumber)
|
||||
/// </summary>
|
||||
public class RobotController : IRobotController
|
||||
{
|
||||
private readonly IRobotConnectionsService _robotConnectionsService;
|
||||
private readonly IConnectionConfig _configManager;
|
||||
private readonly ITrafficControlService _trafficControlService;
|
||||
private readonly IServiceScopeFactory _serviceScopeFactory;
|
||||
private readonly Logger<RobotController> _logger;
|
||||
private readonly SemaphoreSlim _methodLock = new(1, 1);
|
||||
private readonly SemaphoreSlim _publishLock = new(1, 1);
|
||||
|
||||
private uint _headerIdCounter = 0;
|
||||
private readonly Lock _headerIdLock = new();
|
||||
|
||||
private readonly TimeSpan SendTimeOut = TimeSpan.FromSeconds(10);
|
||||
|
||||
public string RobotId { get; }
|
||||
public RobotData Data { get; }
|
||||
public bool IsOnline => Data.ConnectionState == ConnectionState.ONLINE;
|
||||
public bool IsReady => IsOnline && !IsRobotBusy();
|
||||
|
||||
public RobotController(
|
||||
string robotId,
|
||||
IRobotConnectionsService robotConnectionsService,
|
||||
IConnectionConfig configManager,
|
||||
ITrafficControlService trafficControlService,
|
||||
IServiceScopeFactory serviceScopeFactory,
|
||||
Logger<RobotController> logger)
|
||||
{
|
||||
RobotId = robotId;
|
||||
_robotConnectionsService = robotConnectionsService;
|
||||
_configManager = configManager;
|
||||
_trafficControlService = trafficControlService;
|
||||
_serviceScopeFactory = serviceScopeFactory;
|
||||
_logger = logger;
|
||||
|
||||
Data = new RobotData
|
||||
{
|
||||
RobotId = robotId,
|
||||
ConnectionState = ConnectionState.OFFLINE,
|
||||
LastUpdated = DateTime.UtcNow
|
||||
};
|
||||
}
|
||||
|
||||
public async Task<MessageResult> MoveToNodeAsync(string nodeName, double? angle, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (string.IsNullOrEmpty(nodeName))
|
||||
{
|
||||
_logger.Warning($"Cannot move to node: nodeName is null or empty for robot {RobotId}");
|
||||
return new(false, $"Cannot move to node: nodeName is null or empty for robot {RobotId}");
|
||||
}
|
||||
|
||||
await _methodLock.WaitAsync(cancellationToken);
|
||||
try
|
||||
{
|
||||
// 1. Check if robot is busy with an order
|
||||
if (IsRobotBusy())
|
||||
{
|
||||
_logger.Warning($"Cannot move to node: robot {RobotId} is busy with an order {Data.State?.OrderId}");
|
||||
return new(false, $"Robot {RobotId} is busy with an order");
|
||||
}
|
||||
|
||||
// 2. Get robot's levelId (MapId)
|
||||
var levelId = await GetRobotLevelIdAsync();
|
||||
if (levelId == null)
|
||||
{
|
||||
_logger.Warning($"Cannot move to node: robot {RobotId} has no MapId assigned");
|
||||
return new(false, $"Robot {RobotId} has no MapId assigned");
|
||||
}
|
||||
|
||||
// 3. Find goal node by NodeName
|
||||
var goalNode = await FindNodeByNameAsync(levelId.Value, nodeName);
|
||||
if (goalNode == null)
|
||||
{
|
||||
_logger.Warning($"Cannot move to node: node '{nodeName}' not found in level {levelId}");
|
||||
return new(false, $"Node '{nodeName}' not found");
|
||||
}
|
||||
|
||||
// 4. Plan route and send order
|
||||
return await PlanRouteAndSendOrderAsync(goalNode.Id, angle, cancellationToken);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Error($"Error in MoveToNodeAsync for robot {RobotId}: {ex.Message}");
|
||||
return new(false, $"Error in MoveToNodeAsync for robot {RobotId}: {ex.Message}");
|
||||
}
|
||||
finally
|
||||
{
|
||||
_methodLock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Move robot to a node by NodeId (string)
|
||||
/// </summary>
|
||||
public async Task<MessageResult> MoveToNodeByIdAsync(string nodeId, double? angle, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (string.IsNullOrEmpty(nodeId))
|
||||
{
|
||||
_logger.Warning($"Cannot move to node: nodeId is null or empty for robot {RobotId}");
|
||||
return new(false, $"Cannot move to node: nodeId is null or empty for robot {RobotId}");
|
||||
}
|
||||
|
||||
await _methodLock.WaitAsync(cancellationToken);
|
||||
try
|
||||
{
|
||||
// 1. Check if robot is busy with an order
|
||||
if (IsRobotBusy())
|
||||
{
|
||||
_logger.Warning($"Cannot move to node: robot {RobotId} is busy with an order {Data.State?.OrderId}");
|
||||
return new(false, $"Robot {RobotId} is busy with an order");
|
||||
}
|
||||
|
||||
// 2. Get robot's levelId (MapId)
|
||||
var levelId = await GetRobotLevelIdAsync();
|
||||
if (levelId == null)
|
||||
{
|
||||
_logger.Warning($"Cannot move to node: robot {RobotId} has no MapId assigned");
|
||||
return new(false, $"Robot {RobotId} has no MapId assigned");
|
||||
}
|
||||
|
||||
// 3. Find goal node by NodeId (string)
|
||||
var goalNode = await FindNodeByNodeIdAsync(levelId.Value, nodeId);
|
||||
if (goalNode == null)
|
||||
{
|
||||
_logger.Warning($"Cannot move to node: node with NodeId '{nodeId}' not found in level {levelId}");
|
||||
return new(false, $"Node with NodeId '{nodeId}' not found");
|
||||
}
|
||||
|
||||
// 4. Plan route and send order
|
||||
return await PlanRouteAndSendOrderAsync(goalNode.Id, angle, cancellationToken);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Error($"Error in MoveToNodeByIdAsync for robot {RobotId}: {ex.Message}");
|
||||
return new(false, $"Error in MoveToNodeByIdAsync for robot {RobotId}: {ex.Message}");
|
||||
}
|
||||
finally
|
||||
{
|
||||
_methodLock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Move robot to a node by NodeId (Guid)
|
||||
/// </summary>
|
||||
public async Task<MessageResult> MoveToNodeByGuidAsync(Guid nodeId, double? angle, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (nodeId == Guid.Empty)
|
||||
{
|
||||
_logger.Warning($"Cannot move to node: nodeId is empty for robot {RobotId}");
|
||||
return new(false, $"Cannot move to node: nodeId is empty for robot {RobotId}");
|
||||
}
|
||||
|
||||
await _methodLock.WaitAsync(cancellationToken);
|
||||
try
|
||||
{
|
||||
// 1. Check if robot is busy with an order
|
||||
if (IsRobotBusy())
|
||||
{
|
||||
_logger.Warning($"Cannot move to node: robot {RobotId} is busy with an order {Data.State?.OrderId}");
|
||||
return new(false, $"Robot {RobotId} is busy with an order");
|
||||
}
|
||||
|
||||
// 2. Verify node exists
|
||||
using var scope = _serviceScopeFactory.CreateScope();
|
||||
var nodeService = scope.ServiceProvider.GetRequiredService<INodeService>();
|
||||
var goalNode = await nodeService.GetByIdAsync(nodeId);
|
||||
if (goalNode == null)
|
||||
{
|
||||
_logger.Warning($"Cannot move to node: node with Id '{nodeId}' not found");
|
||||
return new(false, $"Node with Id '{nodeId}' not found");
|
||||
}
|
||||
|
||||
// 3. Plan route and send order
|
||||
return await PlanRouteAndSendOrderAsync(nodeId, angle, cancellationToken);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Error($"Error in MoveToNodeByGuidAsync for robot {RobotId}: {ex.Message}");
|
||||
return new(false, $"Error in MoveToNodeByGuidAsync for robot {RobotId}: {ex.Message}");
|
||||
}
|
||||
finally
|
||||
{
|
||||
_methodLock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
public Task<MessageResult> MoveToStationAsync(string nodeName, StationAction action, CancellationToken cancellationToken = default)
|
||||
{
|
||||
throw new NotImplementedException();
|
||||
}
|
||||
|
||||
public async Task<MessageResult> SendInstantActionAsync(Action action, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (action == null)
|
||||
{
|
||||
_logger.Warning($"Cannot send instant action: action is null for robot {RobotId}");
|
||||
return new(false, $"Cannot send instant action: action is null for robot {RobotId}");
|
||||
}
|
||||
|
||||
await _methodLock.WaitAsync(cancellationToken);
|
||||
try
|
||||
{
|
||||
var instantActions = new InstantActionsMsg
|
||||
{
|
||||
Actions = [action]
|
||||
};
|
||||
|
||||
FillVDA5050Header(instantActions);
|
||||
var publish = await _robotConnectionsService.PublishInstantActionsAsync(RobotId, instantActions, cancellationToken);
|
||||
return new(publish);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Error($"Error sending instant action to robot {RobotId}: {ex.Message}");
|
||||
return new(false, $"Error sending instant action to robot {RobotId}: {ex.Message}");
|
||||
}
|
||||
finally
|
||||
{
|
||||
_methodLock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<MessageResult> SendOrderAsync(OrderMsg order, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (order == null)
|
||||
{
|
||||
_logger.Warning($"Cannot send order: order message is null for robot {RobotId}");
|
||||
return new(false, $"Cannot send order: order message is null for robot {RobotId}");
|
||||
}
|
||||
|
||||
await _publishLock.WaitAsync(cancellationToken);
|
||||
try
|
||||
{
|
||||
// Fill header if not already set
|
||||
if (order.HeaderId == 0)
|
||||
{
|
||||
FillVDA5050Header(order);
|
||||
}
|
||||
else
|
||||
{
|
||||
// Ensure SerialNumber matches
|
||||
order.SerialNumber = RobotId;
|
||||
}
|
||||
|
||||
var published = await _robotConnectionsService.PublishOrderAsync(RobotId, order, cancellationToken);
|
||||
|
||||
// Update order in RobotData when successfully published
|
||||
if (published)
|
||||
{
|
||||
CancellationTokenSource cancelSend = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
||||
cancelSend.CancelAfter(SendTimeOut);
|
||||
while (true)
|
||||
{
|
||||
if (cancelSend.IsCancellationRequested) return new(false, "Publish order failed timout");
|
||||
if (Data.State is not null)
|
||||
{
|
||||
if (Data.State.OrderId == order.OrderId && IsRobotBusy() && Data.State.OrderUpdateId == order.OrderUpdateId)
|
||||
{
|
||||
Data.LastUpdated = DateTime.UtcNow;
|
||||
Data.Order = order;
|
||||
return new(true);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return new(false, "Publish order failed");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Error($"Error sending order to robot {RobotId}: {ex.Message}");
|
||||
return new(false, $"Error sending order to robot {RobotId}");
|
||||
}
|
||||
finally
|
||||
{
|
||||
_publishLock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<MessageResult> SendInstantActionsAsync(InstantActionsMsg instantActions, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (instantActions == null)
|
||||
{
|
||||
_logger.Warning($"Cannot send instant actions: instantActions message is null for robot {RobotId}");
|
||||
return new(false, $"Cannot send instant actions: instantActions message is null for robot {RobotId}");
|
||||
}
|
||||
|
||||
if (instantActions.Actions == null || instantActions.Actions.Length == 0)
|
||||
{
|
||||
_logger.Warning($"Cannot send instant actions: actions array is null or empty for robot {RobotId}");
|
||||
return new(false, $"Cannot send instant actions: actions array is null or empty for robot {RobotId}");
|
||||
}
|
||||
|
||||
await _publishLock.WaitAsync(cancellationToken);
|
||||
try
|
||||
{
|
||||
// Fill header if not already set
|
||||
if (instantActions.HeaderId == 0)
|
||||
{
|
||||
FillVDA5050Header(instantActions);
|
||||
}
|
||||
else
|
||||
{
|
||||
// Ensure SerialNumber matches
|
||||
instantActions.SerialNumber = RobotId;
|
||||
}
|
||||
|
||||
var publish = await _robotConnectionsService.PublishInstantActionsAsync(RobotId, instantActions, cancellationToken);
|
||||
return new(publish);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Error($"Error sending instant actions to robot {RobotId}: {ex.Message}");
|
||||
return new(false, $"Error sending instant actions to robot {RobotId}");
|
||||
}
|
||||
finally
|
||||
{
|
||||
_publishLock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<MessageResult> RequestFactsheetAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
await _methodLock.WaitAsync(cancellationToken);
|
||||
try
|
||||
{
|
||||
var action = new Action
|
||||
{
|
||||
ActionType = ActionType.FACTSHEET_REQUEST.ToJsonString(),
|
||||
ActionId = Guid.NewGuid().ToString(),
|
||||
BlockingType = BlockingType.NONE
|
||||
};
|
||||
|
||||
return await SendInstantActionAsync(action, cancellationToken);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Error($"Error requesting factsheet from robot {RobotId}: {ex.Message}");
|
||||
return new(false, $"Error requesting factsheet from robot {RobotId}");
|
||||
}
|
||||
finally
|
||||
{
|
||||
_methodLock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<MessageResult> RequestStateAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
await _methodLock.WaitAsync(cancellationToken);
|
||||
try
|
||||
{
|
||||
var action = new Action
|
||||
{
|
||||
ActionType = ActionType.STATE_REQUEST.ToJsonString(),
|
||||
ActionId = Guid.NewGuid().ToString(),
|
||||
BlockingType = BlockingType.NONE
|
||||
};
|
||||
|
||||
return await SendInstantActionAsync(action, cancellationToken);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Error($"Error requesting state from robot {RobotId}: {ex.Message}");
|
||||
return new(false, $"Error requesting state from robot {RobotId}");
|
||||
}
|
||||
finally
|
||||
{
|
||||
_methodLock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<MessageResult> CancelOrderAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
var action = new Action
|
||||
{
|
||||
ActionType = ActionType.CANCEL_ORDER.ToJsonString(),
|
||||
ActionId = Guid.NewGuid().ToString(),
|
||||
BlockingType = BlockingType.NONE,
|
||||
ActionDescription = "Cancel current order"
|
||||
};
|
||||
|
||||
var result = await SendInstantActionAsync(action, cancellationToken);
|
||||
if (result.IsSuccess && Data.State != null)
|
||||
{
|
||||
// Clear order state immediately so a new order can be accepted without waiting
|
||||
// for the robot to report empty NodeStates/EdgeStates (avoids "robot is busy" after cancel).
|
||||
Data.State.NodeStates = [];
|
||||
Data.State.EdgeStates = [];
|
||||
Data.State.OrderId = string.Empty;
|
||||
Data.State.OrderUpdateId = 0;
|
||||
Data.OrderClearedByCancelAt = DateTime.UtcNow;
|
||||
_logger.Info($"Robot {RobotId}: cleared order state after cancel (ready for new order)");
|
||||
}
|
||||
return result;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Error($"Error canceling order for robot {RobotId}: {ex.Message}");
|
||||
return new(false, $"Error canceling order for robot {RobotId}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Check if robot is busy with an order
|
||||
/// </summary>
|
||||
private bool IsRobotBusy()
|
||||
{
|
||||
// Robot is busy if order status is Sent or Accepted
|
||||
return Data.State?.NodeStates.Length > 0 || Data.State?.EdgeStates.Length > 0;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get robot's levelId (MapId) from database
|
||||
/// </summary>
|
||||
private async Task<Guid?> GetRobotLevelIdAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
using var scope = _serviceScopeFactory.CreateScope();
|
||||
var robotService = scope.ServiceProvider.GetRequiredService<IRobotService>();
|
||||
var robot = await robotService.GetByRobotIdAsync(RobotId);
|
||||
return robot?.MapId; // MapId is the levelId
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Error($"Error getting levelId for robot {RobotId}: {ex.Message}");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Find node by NodeName in a level
|
||||
/// </summary>
|
||||
private async Task<RobotNet10.MapManager.Data.Node?> FindNodeByNameAsync(Guid levelId, string nodeName)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var scope = _serviceScopeFactory.CreateScope();
|
||||
var nodeService = scope.ServiceProvider.GetRequiredService<INodeService>();
|
||||
|
||||
// Get all nodes in level
|
||||
var nodes = await nodeService.GetNodesByLevelAsync(levelId);
|
||||
|
||||
// Find by NodeName (case-insensitive)
|
||||
var node = nodes.FirstOrDefault(n =>
|
||||
!string.IsNullOrEmpty(n.NodeName) &&
|
||||
n.NodeName.Equals(nodeName, StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
return node;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Error($"Error finding node by name '{nodeName}' in level {levelId}: {ex.Message}");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Find node by NodeId (string) in a level
|
||||
/// </summary>
|
||||
private async Task<RobotNet10.MapManager.Data.Node?> FindNodeByNodeIdAsync(Guid levelId, string nodeId)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var scope = _serviceScopeFactory.CreateScope();
|
||||
var nodeService = scope.ServiceProvider.GetRequiredService<INodeService>();
|
||||
|
||||
// Get all nodes in level
|
||||
var nodes = await nodeService.GetNodesByLevelAsync(levelId);
|
||||
|
||||
// Find by NodeId (case-insensitive)
|
||||
var node = nodes.FirstOrDefault(n =>
|
||||
n.NodeId.Equals(nodeId, StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
return node;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Error($"Error finding node by NodeId '{nodeId}' in level {levelId}: {ex.Message}");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Plan route and send order to robot
|
||||
/// </summary>
|
||||
private async Task<MessageResult> PlanRouteAndSendOrderAsync(Guid goalNodeId, double? angle, CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
// Check if robot has state (required for route planning)
|
||||
if (Data.State == null || Data.State.AgvPosition == null)
|
||||
{
|
||||
_logger.Warning($"Cannot plan route: robot {RobotId} has no state or position");
|
||||
return new(false, $"Robot {RobotId} has no state or position");
|
||||
}
|
||||
|
||||
// Cancel current order if exists
|
||||
if (IsRobotBusy())
|
||||
{
|
||||
return new(false, $"The robot is working on order {Data.State.OrderId}");
|
||||
}
|
||||
|
||||
// Plan route using TrafficControlService from current position
|
||||
// Get current position from State.AgvPosition
|
||||
var currentX = Data.State.AgvPosition.X;
|
||||
var currentY = Data.State.AgvPosition.Y;
|
||||
var currentThetaRadians = Data.State.AgvPosition.Theta; // radians
|
||||
var currentThetaDegrees = currentThetaRadians * 180.0 / Math.PI; // convert to degrees
|
||||
|
||||
// Plan route from current position to goal node
|
||||
RobotRoute? route = await _trafficControlService.PlanRouteFromPositionACSTrafficAsync(
|
||||
RobotId,
|
||||
currentX,
|
||||
currentY,
|
||||
currentThetaDegrees,
|
||||
goalNodeId,
|
||||
angle, // goalAngle in degrees
|
||||
null, // startDirection
|
||||
null, // finalDirection
|
||||
cancellationToken);
|
||||
|
||||
if (route == null)
|
||||
{
|
||||
_logger.Warning($"Cannot plan route to node {goalNodeId} for robot {RobotId}");
|
||||
return new(false, $"Failed to plan route to node {goalNodeId}");
|
||||
}
|
||||
return new(true);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Error($"Error planning route and sending order for robot {RobotId}: {ex.Message}");
|
||||
return new(false, $"Error planning route: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private uint GetNextHeaderId()
|
||||
{
|
||||
lock (_headerIdLock)
|
||||
{
|
||||
_headerIdCounter++;
|
||||
if (_headerIdCounter == 0) // Handle overflow
|
||||
{
|
||||
_headerIdCounter = 1;
|
||||
}
|
||||
return _headerIdCounter;
|
||||
}
|
||||
}
|
||||
|
||||
private void FillVDA5050Header(OrderMsg msg)
|
||||
{
|
||||
var config = _configManager.GetVDA5050Config();
|
||||
msg.HeaderId = GetNextHeaderId();
|
||||
msg.Timestamp = DateTime.UtcNow;
|
||||
msg.Version = config.Version;
|
||||
msg.Manufacturer = config.Manufacturer;
|
||||
msg.SerialNumber = RobotId;
|
||||
}
|
||||
|
||||
private void FillVDA5050Header(InstantActionsMsg msg)
|
||||
{
|
||||
var config = _configManager.GetVDA5050Config();
|
||||
msg.HeaderId = 1;
|
||||
msg.Timestamp = DateTime.UtcNow;
|
||||
msg.Version = config.Version;
|
||||
msg.Manufacturer = config.Manufacturer;
|
||||
msg.SerialNumber = RobotId;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
// Cleanup resources if needed
|
||||
_methodLock?.Dispose();
|
||||
// RobotData will be cleaned up by GC
|
||||
_logger.Debug($"RobotController disposed for robot {RobotId}");
|
||||
GC.SuppressFinalize(this);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
using RobotNet10.FleetManager.Script;
|
||||
using RobotNet10.FleetManager.Services.RobotController;
|
||||
using RobotNet10.FleetManager.Services.RobotManager.Models;
|
||||
|
||||
namespace RobotNet10.FleetManager.Services.RobotManager;
|
||||
|
||||
/// <summary>
|
||||
/// Service for managing RobotController instances and routing events
|
||||
/// </summary>
|
||||
public interface IRobotManagerService
|
||||
{
|
||||
/// <summary>
|
||||
/// Get RobotController instance for a robot
|
||||
/// </summary>
|
||||
IRobotController? GetRobotController(string robotId);
|
||||
|
||||
/// <summary>
|
||||
/// Get all RobotController instances
|
||||
/// </summary>
|
||||
IReadOnlyDictionary<string, IRobotController> GetAllRobotControllers();
|
||||
|
||||
/// <summary>
|
||||
/// Remove RobotController instance (when robot is deleted from DB)
|
||||
/// </summary>
|
||||
void RemoveRobotController(string robotId);
|
||||
|
||||
/// <summary>
|
||||
/// Get RobotData for a robot (backward compatibility)
|
||||
/// </summary>
|
||||
RobotData? GetRobotData(string robotId);
|
||||
|
||||
/// <summary>
|
||||
/// Get all RobotData (backward compatibility)
|
||||
/// </summary>
|
||||
IReadOnlyDictionary<string, RobotData> GetAllRobotData();
|
||||
|
||||
/// <summary>
|
||||
/// Get list of available (online) robots
|
||||
/// </summary>
|
||||
IReadOnlyList<string> GetAvailableRobots();
|
||||
|
||||
/// <summary>
|
||||
/// Get list of available robots with condititions
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<string>> GetAvailableRobots(string layout, string version, string level, string model, Func<RobotState, bool> func);
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
namespace RobotNet10.FleetManager.Services.RobotManager.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Order status enumeration
|
||||
/// </summary>
|
||||
//public enum OrderStatus
|
||||
//{
|
||||
// Pending, // Order đã tạo nhưng chưa gửi
|
||||
// Sent, // Order đã gửi qua MQTT
|
||||
// Accepted, // Robot đã accept order
|
||||
// Rejected, // Robot reject order
|
||||
// Completed, // Order hoàn thành
|
||||
// Failed // Order failed
|
||||
//}
|
||||
@@ -0,0 +1,55 @@
|
||||
using RobotNet.VDA5050.Connection;
|
||||
using RobotNet.VDA5050.Factsheet;
|
||||
using RobotNet.VDA5050.Order;
|
||||
using RobotNet.VDA5050.State;
|
||||
using RobotNet.VDA5050.Type;
|
||||
using RobotNet.VDA5050.Visualization;
|
||||
|
||||
namespace RobotNet10.FleetManager.Services.RobotManager.Models;
|
||||
|
||||
/// <summary>
|
||||
/// RobotData chứa tất cả thông tin liên quan đến 1 robot
|
||||
/// </summary>
|
||||
public class RobotData
|
||||
{
|
||||
/// <summary>
|
||||
/// Robot ID (SerialNumber)
|
||||
/// </summary>
|
||||
public string RobotId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Latest State message
|
||||
/// </summary>
|
||||
public StateMsg? State { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Latest Connection state
|
||||
/// </summary>
|
||||
public ConnectionState ConnectionState { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Latest Order message
|
||||
/// </summary>
|
||||
public OrderMsg? Order { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Latest Factsheet message
|
||||
/// </summary>
|
||||
public FactSheetMsg? Factsheet { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Latest Visualization message
|
||||
/// </summary>
|
||||
public VisualizationMsg? Visualization { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Last updated timestamp
|
||||
/// </summary>
|
||||
public DateTime LastUpdated { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// When order state was cleared by cancel (so new order can be accepted before robot reports empty state).
|
||||
/// Used to avoid overwriting with stale state from robot; cleared when robot sends empty NodeStates/EdgeStates or after timeout.
|
||||
/// </summary>
|
||||
public DateTime? OrderClearedByCancelAt { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,430 @@
|
||||
using RobotNet.VDA5050.State;
|
||||
using RobotNet.VDA5050.Type;
|
||||
using RobotNet10.Common;
|
||||
using RobotNet10.FleetManager.Controllers;
|
||||
using RobotNet10.FleetManager.Events;
|
||||
using RobotNet10.FleetManager.Events.Events;
|
||||
using RobotNet10.FleetManager.Script;
|
||||
using RobotNet10.FleetManager.Services.ConfigManager;
|
||||
using RobotNet10.FleetManager.Services.RobotConnections;
|
||||
using RobotNet10.FleetManager.Services.RobotController;
|
||||
using RobotNet10.FleetManager.Services.RobotManager.Models;
|
||||
using RobotNet10.FleetManager.Services.TrafficControl;
|
||||
using RobotNet10.MapManager.Services;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Data;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace RobotNet10.FleetManager.Services.RobotManager;
|
||||
|
||||
/// <summary>
|
||||
/// Service implementation for managing RobotController instances and routing events
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// This service:
|
||||
/// - Manages RobotController instances per robot (instance per robot)
|
||||
/// - Subscribes to Event Bus events and routes to RobotController
|
||||
/// - Auto-creates RobotController when receiving first Connection/State message
|
||||
/// - Timeout monitoring: 30s no State/Visualization → OFFLINE
|
||||
/// </remarks>
|
||||
public class RobotManagerService : BackgroundService, IRobotManagerService
|
||||
{
|
||||
private readonly IRobotEventBus _eventBus;
|
||||
private readonly IRobotConnectionsService _robotConnectionsService;
|
||||
private readonly IConnectionConfig _configManager;
|
||||
private readonly ITrafficControlService _trafficControlService;
|
||||
private readonly IServiceScopeFactory _serviceScopeFactory;
|
||||
private readonly ILoggerFactory _loggerFactory;
|
||||
private readonly Logger<RobotManagerService> _logger;
|
||||
private readonly Logger<RobotController.RobotController> _loggerRobotController;
|
||||
|
||||
// RobotController instances - thread-safe dictionary
|
||||
private readonly ConcurrentDictionary<string, IRobotController> _robotControllers = new();
|
||||
|
||||
// Timeout tracking - last update time for State and Visualization per robot
|
||||
private readonly ConcurrentDictionary<string, (DateTime? lastStateUpdate, DateTime? lastVisualizationUpdate)> _lastUpdateTimes = new();
|
||||
|
||||
// Timeout monitoring timer
|
||||
private WatchTimerAsync<RobotManagerService>? _timeoutTimer;
|
||||
private const int TimeoutCheckIntervalMs = 15000; // 15 seconds
|
||||
private const int TimeoutThresholdSeconds = 30; // 30 seconds
|
||||
|
||||
public RobotManagerService(
|
||||
IRobotEventBus eventBus,
|
||||
IRobotConnectionsService robotConnectionsService,
|
||||
IConnectionConfig configManager,
|
||||
ITrafficControlService trafficControlService,
|
||||
IServiceScopeFactory serviceScopeFactory,
|
||||
ILoggerFactory loggerFactory,
|
||||
Logger<RobotManagerService> logger,
|
||||
Logger<RobotController.RobotController> loggerRobotController)
|
||||
{
|
||||
_eventBus = eventBus;
|
||||
_robotConnectionsService = robotConnectionsService;
|
||||
_configManager = configManager;
|
||||
_trafficControlService = trafficControlService;
|
||||
_serviceScopeFactory = serviceScopeFactory;
|
||||
_loggerFactory = loggerFactory;
|
||||
_logger = logger;
|
||||
_loggerRobotController = loggerRobotController;
|
||||
|
||||
// Subscribe to Event Bus events
|
||||
_eventBus.StateMessageReceived += OnStateMessageReceived;
|
||||
_eventBus.ConnectionStateChanged += OnConnectionStateChanged;
|
||||
_eventBus.VisualizationMessageReceived += OnVisualizationMessageReceived;
|
||||
_eventBus.FactsheetMessageReceived += OnFactsheetMessageReceived;
|
||||
}
|
||||
|
||||
public IRobotController? GetRobotController(string robotId)
|
||||
{
|
||||
_robotControllers.TryGetValue(robotId, out var controller);
|
||||
return controller;
|
||||
}
|
||||
|
||||
public IReadOnlyDictionary<string, IRobotController> GetAllRobotControllers()
|
||||
{
|
||||
return _robotControllers;
|
||||
}
|
||||
|
||||
public void RemoveRobotController(string robotId)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (_robotControllers.TryRemove(robotId, out var controller))
|
||||
{
|
||||
controller.Dispose();
|
||||
_lastUpdateTimes.TryRemove(robotId, out _);
|
||||
_logger.Info($"Removed RobotController for robot {robotId}");
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Error($"Error removing RobotController for robot {robotId}: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
public RobotData? GetRobotData(string robotId)
|
||||
{
|
||||
var controller = GetRobotController(robotId);
|
||||
return controller?.Data;
|
||||
}
|
||||
|
||||
public IReadOnlyDictionary<string, RobotData> GetAllRobotData()
|
||||
{
|
||||
return _robotControllers.ToDictionary(
|
||||
kvp => kvp.Key,
|
||||
kvp => kvp.Value.Data
|
||||
);
|
||||
}
|
||||
|
||||
public IReadOnlyList<string> GetAvailableRobots()
|
||||
{
|
||||
return [.. _robotControllers
|
||||
.Where(kvp => kvp.Value.IsOnline)
|
||||
.Select(kvp => kvp.Key)];
|
||||
}
|
||||
|
||||
private static readonly TimeSpan OrderClearedByCancelWindow = TimeSpan.FromSeconds(15);
|
||||
|
||||
private void OnStateMessageReceived(object? sender, StateMessageReceivedEvent e)
|
||||
{
|
||||
try
|
||||
{
|
||||
var robotId = e.RobotId;
|
||||
var stateMsg = e.StateMessage;
|
||||
|
||||
// Get or create RobotController
|
||||
var controller = GetOrCreateRobotController(robotId);
|
||||
if (controller == null) return;
|
||||
|
||||
// Update State in RobotData
|
||||
controller.Data.State = stateMsg;
|
||||
controller.Data.LastUpdated = DateTime.UtcNow;
|
||||
|
||||
var hasOrderState = (stateMsg.NodeStates?.Length ?? 0) > 0 || (stateMsg.EdgeStates?.Length ?? 0) > 0;
|
||||
var cancelOrderTimedOut = stateMsg.ActionStates?.Any(a =>
|
||||
(string.Equals(a.ActionType, "CANCEL_ORDER", StringComparison.OrdinalIgnoreCase) || a.ActionType?.Contains("cancelOrder", StringComparison.OrdinalIgnoreCase) == true) &&
|
||||
a.ActionStatus == ActionStatus.FAILED &&
|
||||
(a.ResultDescription?.Contains("Timeout", StringComparison.OrdinalIgnoreCase) ?? false)) == true;
|
||||
|
||||
// Force-clear order state when cancel was requested but timed out on robot (so FleetManager still shows robot as not busy)
|
||||
if (hasOrderState && cancelOrderTimedOut)
|
||||
{
|
||||
controller.Data.State.NodeStates = [];
|
||||
controller.Data.State.EdgeStates = [];
|
||||
controller.Data.State.OrderId = string.Empty;
|
||||
controller.Data.State.OrderUpdateId = 0;
|
||||
}
|
||||
// After cancel we clear order state locally; don't let stale robot state overwrite it until robot reports empty or window expires
|
||||
else
|
||||
{
|
||||
var clearedAt = controller.Data.OrderClearedByCancelAt;
|
||||
if (clearedAt.HasValue)
|
||||
{
|
||||
var elapsed = DateTime.UtcNow - clearedAt.Value;
|
||||
if (elapsed >= OrderClearedByCancelWindow)
|
||||
{
|
||||
controller.Data.OrderClearedByCancelAt = null;
|
||||
}
|
||||
else if (hasOrderState)
|
||||
{
|
||||
// Robot hasn't reported empty yet; keep order state cleared so new order can be accepted
|
||||
controller.Data.State.NodeStates = [];
|
||||
controller.Data.State.EdgeStates = [];
|
||||
controller.Data.State.OrderId = string.Empty;
|
||||
controller.Data.State.OrderUpdateId = 0;
|
||||
}
|
||||
else
|
||||
{
|
||||
controller.Data.OrderClearedByCancelAt = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Update last State update time
|
||||
if(controller.Data.ConnectionState != RobotNet.VDA5050.Type.ConnectionState.ONLINE) controller.Data.ConnectionState = RobotNet.VDA5050.Type.ConnectionState.ONLINE;
|
||||
UpdateLastStateTime(robotId);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Error($"Error processing state message for robot {e.RobotId}: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private void OnConnectionStateChanged(object? sender, ConnectionStateChangedEvent e)
|
||||
{
|
||||
try
|
||||
{
|
||||
var robotId = e.RobotId;
|
||||
var connectionState = e.ConnectionState;
|
||||
|
||||
// Get or create RobotController
|
||||
var controller = GetOrCreateRobotController(robotId);
|
||||
if (controller == null) return;
|
||||
|
||||
// Update Connection State in RobotData
|
||||
controller.Data.ConnectionState = connectionState;
|
||||
controller.Data.LastUpdated = DateTime.UtcNow;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Error($"Error processing connection state change for robot {e.RobotId}: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private void OnVisualizationMessageReceived(object? sender, VisualizationMessageReceivedEvent e)
|
||||
{
|
||||
try
|
||||
{
|
||||
var robotId = e.RobotId;
|
||||
var visualizationMsg = e.VisualizationMessage;
|
||||
|
||||
// Get or create RobotController
|
||||
var controller = GetOrCreateRobotController(robotId);
|
||||
if (controller == null) return;
|
||||
|
||||
// Update Visualization in RobotData
|
||||
controller.Data.Visualization = visualizationMsg;
|
||||
controller.Data.LastUpdated = DateTime.UtcNow;
|
||||
|
||||
// Update last Visualization update time
|
||||
UpdateLastVisualizationTime(robotId);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Error($"Error processing visualization message for robot {e.RobotId}: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private void OnFactsheetMessageReceived(object? sender, FactsheetMessageReceivedEvent e)
|
||||
{
|
||||
try
|
||||
{
|
||||
var robotId = e.RobotId;
|
||||
var factsheetMsg = e.FactsheetMessage;
|
||||
|
||||
// Get or create RobotController
|
||||
var controller = GetOrCreateRobotController(robotId);
|
||||
if (controller == null) return;
|
||||
|
||||
// Update Factsheet in RobotData
|
||||
controller.Data.Factsheet = factsheetMsg;
|
||||
controller.Data.LastUpdated = DateTime.UtcNow;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Error($"Error processing factsheet message for robot {e.RobotId}: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private IRobotController? GetOrCreateRobotController(string robotId)
|
||||
{
|
||||
// Check if already exists
|
||||
if (_robotControllers.TryGetValue(robotId, out var existingController))
|
||||
{
|
||||
return existingController;
|
||||
}
|
||||
|
||||
// Validate robot exists in database
|
||||
using var scope = _serviceScopeFactory.CreateScope();
|
||||
var _robotService = scope.ServiceProvider.GetRequiredService<IRobotService>();
|
||||
if (!_robotService.ExistsAsync(robotId).Result)
|
||||
{
|
||||
_logger.Warning($"Robot {robotId} not found in database, skipping RobotController creation");
|
||||
return null;
|
||||
}
|
||||
|
||||
// Create new RobotController instance
|
||||
try
|
||||
{
|
||||
var controller = new Services.RobotController.RobotController(
|
||||
robotId,
|
||||
_robotConnectionsService,
|
||||
_configManager,
|
||||
_trafficControlService,
|
||||
_serviceScopeFactory,
|
||||
_loggerRobotController
|
||||
);
|
||||
|
||||
if (_robotControllers.TryAdd(robotId, controller))
|
||||
{
|
||||
_lastUpdateTimes.TryAdd(robotId, (null, null));
|
||||
_logger.Info($"Created RobotController for robot {robotId}");
|
||||
return controller;
|
||||
}
|
||||
else
|
||||
{
|
||||
// Another thread created it, dispose this one and get the existing
|
||||
((IDisposable)controller).Dispose();
|
||||
_robotControllers.TryGetValue(robotId, out var createdController);
|
||||
return createdController;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Error($"Error creating RobotController for robot {robotId}: {ex.Message}");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<string>> GetAvailableRobots(string layout, string version, string level, string model, Func<RobotState, bool> func)
|
||||
{
|
||||
using var scope = _serviceScopeFactory.CreateScope();
|
||||
var _robotService = scope.ServiceProvider.GetRequiredService<IRobotService>();
|
||||
var _layoutService = scope.ServiceProvider.GetRequiredService<ILayoutService>();
|
||||
|
||||
var layoutDb = await _layoutService.GetLayoutByNameAsync(layout);
|
||||
if (layoutDb is null) return [];
|
||||
var levelDb = layoutDb.Versions.FirstOrDefault(v => v.Version == version)?.Levels.FirstOrDefault(l => l.LayoutLevelId == level);
|
||||
if (levelDb is null) return [];
|
||||
|
||||
var robotDbs = await _robotService.GetByModelNameAsync(model);
|
||||
if (robotDbs is null) return [];
|
||||
|
||||
var robotDbinMap = robotDbs.Where(r => r.MapId == levelDb.Id);
|
||||
List<IRobotController> robotControllers = [.. _robotControllers.Where(kvp => kvp.Value.IsOnline && robotDbinMap.Any(r => r.RobotId == kvp.Key)).Select(kvp => kvp.Value)];
|
||||
return [..robotControllers.Where(r => r.IsOnline && r.Data is not null && r.Data.State is not null && r.Data.Visualization is not null && func(ToRobotState(r))).Select(r => r.RobotId)];
|
||||
}
|
||||
|
||||
private static RobotState ToRobotState(IRobotController robotController)
|
||||
{
|
||||
return new RobotState(robotController.IsReady,
|
||||
robotController.Data.State?.BatteryState.BatteryVoltage ?? 0,
|
||||
robotController.Data.State?.Loads ?? [],
|
||||
robotController.Data.State?.BatteryState.Charging ?? false,
|
||||
robotController.Data.Visualization?.AgvPosition.X ?? 0,
|
||||
robotController.Data.Visualization?.AgvPosition.Y ?? 0,
|
||||
robotController.Data.Visualization?.AgvPosition.Theta ?? 0);
|
||||
}
|
||||
|
||||
private void UpdateLastStateTime(string robotId)
|
||||
{
|
||||
_lastUpdateTimes.AddOrUpdate(robotId,
|
||||
(DateTime.UtcNow, null),
|
||||
(key, oldValue) => (DateTime.UtcNow, oldValue.lastVisualizationUpdate));
|
||||
}
|
||||
|
||||
private void UpdateLastVisualizationTime(string robotId)
|
||||
{
|
||||
_lastUpdateTimes.AddOrUpdate(robotId,
|
||||
(null, DateTime.UtcNow),
|
||||
(key, oldValue) => (oldValue.lastStateUpdate, DateTime.UtcNow));
|
||||
}
|
||||
|
||||
private async Task CheckTimeouts()
|
||||
{
|
||||
try
|
||||
{
|
||||
var now = DateTime.UtcNow;
|
||||
var robotsToCheck = _robotControllers.Keys.ToList();
|
||||
|
||||
foreach (var robotId in robotsToCheck)
|
||||
{
|
||||
if (!_lastUpdateTimes.TryGetValue(robotId, out var updateTimes))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var stateTimeout = updateTimes.lastStateUpdate.HasValue &&
|
||||
(now - updateTimes.lastStateUpdate.Value).TotalSeconds > TimeoutThresholdSeconds;
|
||||
|
||||
var visualizationTimeout = updateTimes.lastVisualizationUpdate.HasValue &&
|
||||
(now - updateTimes.lastVisualizationUpdate.Value).TotalSeconds > TimeoutThresholdSeconds;
|
||||
|
||||
// If both State and Visualization timeout, set OFFLINE
|
||||
if (stateTimeout && visualizationTimeout)
|
||||
{
|
||||
if (_robotControllers.TryGetValue(robotId, out var controller))
|
||||
{
|
||||
if (controller.Data.ConnectionState != RobotNet.VDA5050.Type.ConnectionState.OFFLINE)
|
||||
{
|
||||
controller.Data.ConnectionState = RobotNet.VDA5050.Type.ConnectionState.OFFLINE;
|
||||
controller.Data.LastUpdated = now;
|
||||
_logger.Warning($"Robot {robotId} timed out (30s no State/Visualization), set to OFFLINE");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Error($"Error in timeout check: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
await Task.Yield();
|
||||
await _robotConnectionsService.StartAsync(stoppingToken);
|
||||
|
||||
_timeoutTimer = new WatchTimerAsync<RobotManagerService>(
|
||||
TimeoutCheckIntervalMs,
|
||||
CheckTimeouts,
|
||||
_loggerFactory.CreateLogger<RobotManagerService>()
|
||||
);
|
||||
_timeoutTimer.Start();
|
||||
_logger.Info("Started timeout monitoring (15s interval, 30s threshold)");
|
||||
}
|
||||
|
||||
public override Task StopAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
_timeoutTimer?.Dispose();
|
||||
|
||||
// Dispose all RobotController instances
|
||||
foreach (var controller in _robotControllers.Values)
|
||||
{
|
||||
try
|
||||
{
|
||||
controller.Dispose();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Error($"Error disposing RobotController: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
_robotControllers.Clear();
|
||||
_lastUpdateTimes.Clear();
|
||||
return base.StopAsync(cancellationToken);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using RobotNet10.StorageManager;
|
||||
using SixLabors.ImageSharp;
|
||||
|
||||
namespace RobotNet10.FleetManager.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Service implementation for robot model image storage using StorageManager
|
||||
/// Stores images with naming: {robotModelId}.png
|
||||
/// </summary>
|
||||
public class RobotModelImageStorageService : IRobotModelImageStorageService, IDisposable
|
||||
{
|
||||
private readonly ILogger<RobotModelImageStorageService> _logger;
|
||||
private readonly StorageManager.StorageManager _storageManager;
|
||||
private const string ImagePath = "robotModelImages";
|
||||
private const string ContentType = "image/png";
|
||||
|
||||
public RobotModelImageStorageService(IOptionsMonitor<StorageConfig> optionsSnapshot, ILogger<RobotModelImageStorageService> logger)
|
||||
{
|
||||
_logger = logger;
|
||||
|
||||
var config = optionsSnapshot.Get("RobotModelImages");
|
||||
|
||||
ArgumentNullException.ThrowIfNull(config);
|
||||
|
||||
_storageManager = new StorageManager.StorageManager(config);
|
||||
|
||||
if (_logger.IsEnabled(LogLevel.Information))
|
||||
{
|
||||
_logger.LogInformation("RobotModelImageStorageService initialized");
|
||||
}
|
||||
}
|
||||
|
||||
private static string GetObjectName(Guid robotModelId) => robotModelId.ToString();
|
||||
|
||||
public async Task SaveImageAsync(Guid robotModelId, Stream imageStream, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var objectName = GetObjectName(robotModelId);
|
||||
|
||||
try
|
||||
{
|
||||
// Reset stream position if seekable
|
||||
if (imageStream.CanSeek)
|
||||
{
|
||||
imageStream.Position = 0;
|
||||
}
|
||||
|
||||
// Get stream size - handle cases where Length might not be available
|
||||
long size = imageStream.Length;
|
||||
|
||||
// If size is 0 or stream doesn't support Length, copy to MemoryStream
|
||||
if (size == 0 || !imageStream.CanSeek)
|
||||
{
|
||||
using var memoryStream = new MemoryStream();
|
||||
await imageStream.CopyToAsync(memoryStream, cancellationToken);
|
||||
size = memoryStream.Length;
|
||||
memoryStream.Position = 0;
|
||||
|
||||
await _storageManager.UploadAsync(ImagePath, objectName, memoryStream, size, ContentType, cancellationToken);
|
||||
}
|
||||
else
|
||||
{
|
||||
// Stream has valid length and is seekable, use directly
|
||||
await _storageManager.UploadAsync(ImagePath, objectName, imageStream, size, ContentType, cancellationToken);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
if (_logger.IsEnabled(LogLevel.Error)) _logger.LogError(ex, "Failed to save image for robot model {RobotModelId}", robotModelId);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<Stream?> GetImageAsync(Guid robotModelId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var objectName = GetObjectName(robotModelId);
|
||||
|
||||
try
|
||||
{
|
||||
var exists = await _storageManager.ExistsAsync(ImagePath, objectName, cancellationToken);
|
||||
if (!exists)
|
||||
{
|
||||
if (_logger.IsEnabled(LogLevel.Warning)) _logger.LogWarning("Image not found for robot model {RobotModelId}", robotModelId);
|
||||
return null;
|
||||
}
|
||||
|
||||
var stream = await _storageManager.GetFileAsync(ImagePath, objectName, cancellationToken);
|
||||
return stream;
|
||||
}
|
||||
catch (FileNotFoundException)
|
||||
{
|
||||
if (_logger.IsEnabled(LogLevel.Warning)) _logger.LogWarning("Image not found for robot model {RobotModelId}", robotModelId);
|
||||
return null;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
if (_logger.IsEnabled(LogLevel.Error)) _logger.LogError(ex, "Failed to read image for robot model {RobotModelId}", robotModelId);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<bool> DeleteImageAsync(Guid robotModelId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var objectName = GetObjectName(robotModelId);
|
||||
|
||||
try
|
||||
{
|
||||
var exists = await _storageManager.ExistsAsync(ImagePath, objectName, cancellationToken);
|
||||
if (!exists)
|
||||
{
|
||||
if (_logger.IsEnabled(LogLevel.Warning)) _logger.LogWarning("Image not found for deletion: {RobotModelId}", robotModelId);
|
||||
return false;
|
||||
}
|
||||
|
||||
await _storageManager.DeleteAsync(ImagePath, objectName, cancellationToken);
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
if (_logger.IsEnabled(LogLevel.Error)) _logger.LogError(ex, "Failed to delete image for robot model {RobotModelId}", robotModelId);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<bool> ImageExistsAsync(Guid robotModelId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var objectName = GetObjectName(robotModelId);
|
||||
return await _storageManager.ExistsAsync(ImagePath, objectName, cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<(int width, int height)> GetImageDimensionsAsync(Stream imageStream, CancellationToken cancellationToken = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
// Reset stream position if seekable
|
||||
if (imageStream.CanSeek)
|
||||
{
|
||||
imageStream.Position = 0;
|
||||
}
|
||||
|
||||
// Load image to get dimensions
|
||||
using var image = await Image.LoadAsync(imageStream, cancellationToken);
|
||||
|
||||
return (image.Width, image.Height);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to extract image dimensions");
|
||||
throw new InvalidOperationException("Invalid image format or corrupted file", ex);
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_storageManager?.Dispose();
|
||||
GC.SuppressFinalize(this);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,370 @@
|
||||
using RobotNet10.FleetManager.Shared.DTOs.RobotModel;
|
||||
using RobotNet10.MapEditor.Shared.DTOs.Edge;
|
||||
using RobotNet10.MapEditor.Shared.DTOs.Node;
|
||||
using RobotNet10.MapManager.Data;
|
||||
using RobotNet10.MapManager.Services;
|
||||
|
||||
namespace RobotNet10.FleetManager.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Service implementation for managing robot model map data based on VehicleType filtering
|
||||
/// </summary>
|
||||
public class RobotModelMapService(
|
||||
IRobotModelService robotModelService,
|
||||
IVehicleTypeService vehicleTypeService,
|
||||
IMapQueryService mapQueryService,
|
||||
ILayoutService layoutService,
|
||||
Logger<RobotModelMapService> logger) : IRobotModelMapService
|
||||
{
|
||||
private readonly IRobotModelService _robotModelService = robotModelService;
|
||||
private readonly IVehicleTypeService _vehicleTypeService = vehicleTypeService;
|
||||
private readonly IMapQueryService _mapQueryService = mapQueryService;
|
||||
private readonly ILayoutService _layoutService = layoutService;
|
||||
private readonly Logger<RobotModelMapService> _logger = logger;
|
||||
|
||||
public async Task<List<Node>> GetFilteredNodesAsync(Guid robotModelId)
|
||||
{
|
||||
// Get RobotModel
|
||||
var robotModel = await _robotModelService.GetByIdAsync(robotModelId)
|
||||
?? throw new KeyNotFoundException($"RobotModel with ID {robotModelId} not found.");
|
||||
|
||||
// Validate VehicleTypeId is set
|
||||
if (!robotModel.VehicleTypeId.HasValue)
|
||||
{
|
||||
throw new InvalidOperationException($"RobotModel '{robotModel.ModelName}' does not have VehicleTypeId set.");
|
||||
}
|
||||
|
||||
var vehicleTypeId = robotModel.VehicleTypeId.Value;
|
||||
|
||||
// Validate VehicleType exists
|
||||
var vehicleType = await _vehicleTypeService.GetByIdAsync(vehicleTypeId)
|
||||
?? throw new KeyNotFoundException($"VehicleType with ID {vehicleTypeId} not found.");
|
||||
|
||||
if (!vehicleType.IsActive)
|
||||
{
|
||||
_logger.Warning($"VehicleType '{vehicleType.VehicleTypeName}' is not active.");
|
||||
}
|
||||
|
||||
// Get filtered nodes: nodes that have NodeVehicleProperties for this VehicleType
|
||||
var filteredNodes = await _mapQueryService.GetNodesByVehicleTypeAsync(vehicleTypeId);
|
||||
|
||||
_logger.Info($"Found {filteredNodes.Count} filtered nodes for RobotModel '{robotModel.ModelName}' (VehicleType: '{vehicleType.VehicleTypeName}')");
|
||||
|
||||
return filteredNodes;
|
||||
}
|
||||
|
||||
public async Task<List<Edge>> GetFilteredEdgesAsync(Guid robotModelId)
|
||||
{
|
||||
// Get RobotModel
|
||||
var robotModel = await _robotModelService.GetByIdAsync(robotModelId)
|
||||
?? throw new KeyNotFoundException($"RobotModel with ID {robotModelId} not found.");
|
||||
|
||||
// Validate VehicleTypeId is set
|
||||
if (!robotModel.VehicleTypeId.HasValue)
|
||||
{
|
||||
throw new InvalidOperationException($"RobotModel '{robotModel.ModelName}' does not have VehicleTypeId set.");
|
||||
}
|
||||
|
||||
var vehicleTypeId = robotModel.VehicleTypeId.Value;
|
||||
|
||||
// Validate VehicleType exists
|
||||
var vehicleType = await _vehicleTypeService.GetByIdAsync(vehicleTypeId)
|
||||
?? throw new KeyNotFoundException($"VehicleType with ID {vehicleTypeId} not found.");
|
||||
|
||||
// Get filtered edges: edges that have EdgeVehicleProperties for this VehicleType
|
||||
var filteredEdges = await _mapQueryService.GetEdgesByVehicleTypeAsync(vehicleTypeId);
|
||||
|
||||
_logger.Info($"Found {filteredEdges.Count} filtered edges for RobotModel '{robotModel.ModelName}' (VehicleType: '{vehicleType.VehicleTypeName}')");
|
||||
|
||||
return filteredEdges;
|
||||
}
|
||||
|
||||
public async Task<List<Node>> GetFilteredNodesByLevelAsync(Guid robotModelId, Guid levelId)
|
||||
{
|
||||
// Get RobotModel
|
||||
var robotModel = await _robotModelService.GetByIdAsync(robotModelId)
|
||||
?? throw new KeyNotFoundException($"RobotModel with ID {robotModelId} not found.");
|
||||
|
||||
// Validate VehicleTypeId is set
|
||||
if (!robotModel.VehicleTypeId.HasValue)
|
||||
{
|
||||
throw new InvalidOperationException($"RobotModel '{robotModel.ModelName}' does not have VehicleTypeId set.");
|
||||
}
|
||||
|
||||
var vehicleTypeId = robotModel.VehicleTypeId.Value;
|
||||
|
||||
// Validate VehicleType exists
|
||||
var vehicleType = await _vehicleTypeService.GetByIdAsync(vehicleTypeId)
|
||||
?? throw new KeyNotFoundException($"VehicleType with ID {vehicleTypeId} not found.");
|
||||
|
||||
if (!vehicleType.IsActive)
|
||||
{
|
||||
_logger.Warning($"VehicleType '{vehicleType.VehicleTypeName}' is not active.");
|
||||
}
|
||||
|
||||
// Get filtered nodes: nodes that have NodeVehicleProperties for this VehicleType and belong to the specified level
|
||||
var filteredNodes = await _mapQueryService.GetNodesByVehicleTypeAndLevelAsync(vehicleTypeId, levelId);
|
||||
|
||||
_logger.Info($"Found {filteredNodes.Count} filtered nodes for RobotModel '{robotModel.ModelName}' (VehicleType: '{vehicleType.VehicleTypeName}') in LevelId={levelId}");
|
||||
|
||||
return filteredNodes;
|
||||
}
|
||||
|
||||
public async Task<List<Edge>> GetFilteredEdgesByLevelAsync(Guid robotModelId, Guid levelId)
|
||||
{
|
||||
// Get RobotModel
|
||||
var robotModel = await _robotModelService.GetByIdAsync(robotModelId)
|
||||
?? throw new KeyNotFoundException($"RobotModel with ID {robotModelId} not found.");
|
||||
|
||||
// Validate VehicleTypeId is set
|
||||
if (!robotModel.VehicleTypeId.HasValue)
|
||||
{
|
||||
throw new InvalidOperationException($"RobotModel '{robotModel.ModelName}' does not have VehicleTypeId set.");
|
||||
}
|
||||
|
||||
var vehicleTypeId = robotModel.VehicleTypeId.Value;
|
||||
|
||||
// Validate VehicleType exists
|
||||
var vehicleType = await _vehicleTypeService.GetByIdAsync(vehicleTypeId)
|
||||
?? throw new KeyNotFoundException($"VehicleType with ID {vehicleTypeId} not found.");
|
||||
|
||||
// Get filtered edges: edges that have EdgeVehicleProperties for this VehicleType and belong to the specified level
|
||||
var filteredEdges = await _mapQueryService.GetEdgesByVehicleTypeAndLevelAsync(vehicleTypeId, levelId);
|
||||
|
||||
_logger.Info($"Found {filteredEdges.Count} filtered edges for RobotModel '{robotModel.ModelName}' (VehicleType: '{vehicleType.VehicleTypeName}') in LevelId={levelId}");
|
||||
|
||||
return filteredEdges;
|
||||
}
|
||||
|
||||
public async Task<RobotModelMapDataDto> GetValidatedMapDataAsync(Guid robotModelId)
|
||||
{
|
||||
// Get RobotModel
|
||||
var robotModel = await _robotModelService.GetByIdAsync(robotModelId)
|
||||
?? throw new KeyNotFoundException($"RobotModel with ID {robotModelId} not found.");
|
||||
|
||||
// Validate VehicleTypeId is set
|
||||
if (!robotModel.VehicleTypeId.HasValue)
|
||||
{
|
||||
throw new InvalidOperationException($"RobotModel '{robotModel.ModelName}' does not have VehicleTypeId set.");
|
||||
}
|
||||
|
||||
var vehicleTypeId = robotModel.VehicleTypeId.Value;
|
||||
|
||||
// Get VehicleType
|
||||
var vehicleType = await _vehicleTypeService.GetByIdAsync(vehicleTypeId)
|
||||
?? throw new KeyNotFoundException($"VehicleType with ID {vehicleTypeId} not found.");
|
||||
|
||||
// Get filtered nodes and edges (from all levels that match VehicleType)
|
||||
// Note: If RobotModel needs to filter by specific LevelId, MapId should be added to RobotModel
|
||||
var filteredNodes = await GetFilteredNodesAsync(robotModelId);
|
||||
var filteredEdges = await GetFilteredEdgesAsync(robotModelId);
|
||||
|
||||
// Get total counts across all levels (for reference)
|
||||
// In the future, if RobotModel has MapId (LevelId), we can filter by specific level
|
||||
int totalNodesInLevel = await _mapQueryService.GetTotalNodesCountAsync();
|
||||
int totalEdgesInLevel = await _mapQueryService.GetTotalEdgesCountAsync();
|
||||
string? levelName = null;
|
||||
Guid? levelId = null;
|
||||
|
||||
// If we have filtered nodes, get the level from first node (for display)
|
||||
if (filteredNodes.Count > 0)
|
||||
{
|
||||
var firstNode = filteredNodes.First();
|
||||
var level = await _layoutService.GetLevelAsync(firstNode.LevelId);
|
||||
levelName = level?.LayoutLevelId;
|
||||
levelId = level?.Id;
|
||||
}
|
||||
|
||||
// Validate: Remove nodes without edges, edges without both nodes
|
||||
var nodeIds = new HashSet<Guid>(filteredNodes.Select(n => n.Id));
|
||||
var validNodeIds = new HashSet<Guid>();
|
||||
var validEdges = new List<Edge>();
|
||||
|
||||
// First pass: Find edges that have both start and end nodes in filtered nodes
|
||||
foreach (var edge in filteredEdges)
|
||||
{
|
||||
if (nodeIds.Contains(edge.StartNodeId) && nodeIds.Contains(edge.EndNodeId))
|
||||
{
|
||||
validEdges.Add(edge);
|
||||
validNodeIds.Add(edge.StartNodeId);
|
||||
validNodeIds.Add(edge.EndNodeId);
|
||||
}
|
||||
}
|
||||
|
||||
// Second pass: Keep only nodes that are connected by valid edges
|
||||
var validNodes = filteredNodes.Where(n => validNodeIds.Contains(n.Id)).ToList();
|
||||
|
||||
// Count removed items
|
||||
int nodesRemoved = filteredNodes.Count - validNodes.Count;
|
||||
int edgesRemoved = filteredEdges.Count - validEdges.Count;
|
||||
|
||||
// Build validation result
|
||||
var validationResult = new MapValidationResultDto
|
||||
{
|
||||
IsValid = nodesRemoved == 0 && edgesRemoved == 0,
|
||||
NodesRemoved = nodesRemoved,
|
||||
EdgesRemoved = edgesRemoved
|
||||
};
|
||||
|
||||
// Add errors for removed nodes
|
||||
foreach (var node in filteredNodes.Where(n => !validNodeIds.Contains(n.Id)))
|
||||
{
|
||||
validationResult.Errors.Add(new ValidationError
|
||||
{
|
||||
Code = "NODE_NO_EDGES",
|
||||
Message = $"Node '{node.NodeId}' has no connected edges in the filtered map",
|
||||
EntityId = node.Id.ToString(),
|
||||
EntityType = "Node"
|
||||
});
|
||||
}
|
||||
|
||||
// Add errors for removed edges
|
||||
foreach (var edge in filteredEdges.Where(e => !validEdges.Contains(e)))
|
||||
{
|
||||
var missingStart = !nodeIds.Contains(edge.StartNodeId);
|
||||
var missingEnd = !nodeIds.Contains(edge.EndNodeId);
|
||||
|
||||
if (missingStart && missingEnd)
|
||||
{
|
||||
validationResult.Errors.Add(new ValidationError
|
||||
{
|
||||
Code = "EDGE_MISSING_BOTH_NODES",
|
||||
Message = $"Edge '{edge.EdgeId}' is missing both start and end nodes",
|
||||
EntityId = edge.Id.ToString(),
|
||||
EntityType = "Edge"
|
||||
});
|
||||
}
|
||||
else if (missingStart)
|
||||
{
|
||||
validationResult.Errors.Add(new ValidationError
|
||||
{
|
||||
Code = "EDGE_MISSING_START_NODE",
|
||||
Message = $"Edge '{edge.EdgeId}' is missing start node",
|
||||
EntityId = edge.Id.ToString(),
|
||||
EntityType = "Edge"
|
||||
});
|
||||
}
|
||||
else if (missingEnd)
|
||||
{
|
||||
validationResult.Errors.Add(new ValidationError
|
||||
{
|
||||
Code = "EDGE_MISSING_END_NODE",
|
||||
Message = $"Edge '{edge.EdgeId}' is missing end node",
|
||||
EntityId = edge.Id.ToString(),
|
||||
EntityType = "Edge"
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Convert to DTOs
|
||||
var nodeDtos = validNodes.Select(n => new NodeDto
|
||||
{
|
||||
Id = n.Id,
|
||||
LevelId = n.LevelId,
|
||||
NodeId = n.NodeId,
|
||||
NodeName = n.NodeName,
|
||||
NodeDescription = n.NodeDescription,
|
||||
MapId = n.MapId,
|
||||
X = n.X,
|
||||
Y = n.Y,
|
||||
VehicleProperties = [.. n.VehicleProperties
|
||||
.Where(vp => vp.VehicleTypeId == vehicleTypeId)
|
||||
.Select(vp => new NodeVehiclePropertyDto
|
||||
{
|
||||
Id = vp.Id,
|
||||
NodeId = vp.NodeId,
|
||||
VehicleTypeId = vp.VehicleTypeId,
|
||||
Theta = vp.Theta,
|
||||
Actions = vp.Actions
|
||||
})]
|
||||
}).ToList();
|
||||
|
||||
var edgeDtos = validEdges.Select(e => new EdgeDto
|
||||
{
|
||||
Id = e.Id,
|
||||
LevelId = e.LevelId,
|
||||
EdgeId = e.EdgeId,
|
||||
EdgeName = e.EdgeName,
|
||||
EdgeDescription = e.EdgeDescription,
|
||||
StartNodeId = e.StartNodeId,
|
||||
EndNodeId = e.EndNodeId,
|
||||
StartNode = nodeDtos.FirstOrDefault(n => n.Id == e.StartNodeId),
|
||||
EndNode = nodeDtos.FirstOrDefault(n => n.Id == e.EndNodeId),
|
||||
VehicleProperties = [.. e.VehicleProperties
|
||||
.Where(vp => vp.VehicleTypeId == vehicleTypeId)
|
||||
.Select(vp => new EdgeVehiclePropertyDto
|
||||
{
|
||||
Id = vp.Id,
|
||||
EdgeId = vp.EdgeId,
|
||||
VehicleTypeId = vp.VehicleTypeId,
|
||||
VehicleOrientation = vp.VehicleOrientation,
|
||||
OrientationType = vp.OrientationType,
|
||||
RotationAllowed = vp.RotationAllowed,
|
||||
RotationAtStartNodeAllowed = vp.RotationAtStartNodeAllowed,
|
||||
RotationAtEndNodeAllowed = vp.RotationAtEndNodeAllowed,
|
||||
MaxSpeed = vp.MaxSpeed,
|
||||
MaxRotationSpeed = vp.MaxRotationSpeed,
|
||||
MinHeight = vp.MinHeight,
|
||||
MaxHeight = vp.MaxHeight,
|
||||
LoadRestriction = null,
|
||||
Actions = vp.Actions
|
||||
})]
|
||||
}).ToList();
|
||||
|
||||
var result = new RobotModelMapDataDto
|
||||
{
|
||||
RobotModelId = robotModel.Id,
|
||||
RobotModelName = robotModel.ModelName,
|
||||
VehicleTypeId = vehicleTypeId,
|
||||
VehicleTypeName = vehicleType.VehicleTypeName,
|
||||
LevelId = levelId,
|
||||
LevelName = levelName,
|
||||
ValidNodes = nodeDtos,
|
||||
ValidEdges = edgeDtos,
|
||||
TotalNodesInLevel = totalNodesInLevel,
|
||||
TotalEdgesInLevel = totalEdgesInLevel,
|
||||
FilteredNodesCount = filteredNodes.Count,
|
||||
FilteredEdgesCount = filteredEdges.Count,
|
||||
ValidationResult = validationResult
|
||||
};
|
||||
|
||||
_logger.Info($"Validated map data for RobotModel '{robotModel.ModelName}': {validNodes.Count} valid nodes, {validEdges.Count} valid edges (removed {nodesRemoved} nodes, {edgesRemoved} edges)");
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
public async Task<MapValidationResultDto> ValidateMapForRobotModelAsync(Guid robotModelId)
|
||||
{
|
||||
var mapData = await GetValidatedMapDataAsync(robotModelId);
|
||||
return mapData.ValidationResult;
|
||||
}
|
||||
|
||||
public async Task<bool> HasValidMapConfigurationAsync(Guid robotModelId)
|
||||
{
|
||||
try
|
||||
{
|
||||
var robotModel = await _robotModelService.GetByIdAsync(robotModelId);
|
||||
if (robotModel == null)
|
||||
return false;
|
||||
|
||||
if (!robotModel.VehicleTypeId.HasValue)
|
||||
return false;
|
||||
|
||||
// Check if VehicleType exists and is active
|
||||
var vehicleType = await _vehicleTypeService.GetByIdAsync(robotModel.VehicleTypeId.Value);
|
||||
|
||||
if (vehicleType == null || !vehicleType.IsActive)
|
||||
return false;
|
||||
|
||||
// Validate map data
|
||||
var validationResult = await ValidateMapForRobotModelAsync(robotModelId);
|
||||
return validationResult.IsValid;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Error($"Error checking valid map configuration for RobotModel {robotModelId}: {ex.Message}");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,207 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using RobotNet10.FleetManager.Data;
|
||||
using RobotNet10.FleetManager.Events;
|
||||
using RobotNet10.FleetManager.Events.Events;
|
||||
using RobotNet10.FleetManager.Shared.DTOs.RobotModel;
|
||||
|
||||
namespace RobotNet10.FleetManager.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Service implementation for managing robot models.
|
||||
/// Handles business logic for CRUD operations on robot models.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// This service validates business rules such as duplicate model names,
|
||||
/// checks for associated robots before deletion, and provides search functionality.
|
||||
/// </remarks>
|
||||
public class RobotModelService(
|
||||
ApplicationDbContext context,
|
||||
Logger<RobotModelService> logger,
|
||||
IRobotEventBus? eventBus = null) : IRobotModelService
|
||||
{
|
||||
private readonly ApplicationDbContext _context = context;
|
||||
private readonly Logger<RobotModelService> _logger = logger;
|
||||
private readonly IRobotEventBus? _eventBus = eventBus;
|
||||
|
||||
public async Task<RobotModel> CreateAsync(CreateRobotModelRequest request)
|
||||
{
|
||||
// Check if model name already exists
|
||||
if (await ExistsAsync(request.ModelName))
|
||||
{
|
||||
throw new InvalidOperationException($"Robot model with name '{request.ModelName}' already exists.");
|
||||
}
|
||||
|
||||
var robotModel = new RobotModel
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
ModelName = request.ModelName,
|
||||
Length = request.Length,
|
||||
Width = request.Width,
|
||||
ImageWidth = request.ImageWidth,
|
||||
ImageHeight = request.ImageHeight,
|
||||
NavigationPointX = request.NavigationPointX,
|
||||
NavigationPointY = request.NavigationPointY,
|
||||
NavigationType = request.NavigationType,
|
||||
VehicleTypeId = request.VehicleTypeId,
|
||||
CreatedDate = DateTime.UtcNow
|
||||
};
|
||||
|
||||
_context.RobotModels.Add(robotModel);
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
_logger.Info($"Created robot model {robotModel.ModelName} with ID {robotModel.Id}");
|
||||
return robotModel;
|
||||
}
|
||||
|
||||
public async Task<List<RobotModel>> GetAllAsync()
|
||||
{
|
||||
return await _context.RobotModels
|
||||
.AsNoTracking()
|
||||
.OrderBy(rm => rm.ModelName)
|
||||
.ToListAsync();
|
||||
}
|
||||
|
||||
public async Task<RobotModel?> GetByIdAsync(Guid id)
|
||||
{
|
||||
return await _context.RobotModels
|
||||
.AsNoTracking()
|
||||
.Include(rm => rm.Robots)
|
||||
.FirstOrDefaultAsync(rm => rm.Id == id);
|
||||
}
|
||||
|
||||
public async Task<RobotModel> UpdateAsync(Guid id, UpdateRobotModelRequest request)
|
||||
{
|
||||
var robotModel = await _context.RobotModels.FindAsync(id) ?? throw new KeyNotFoundException($"Robot model with ID {id} not found.");
|
||||
|
||||
// Check if model name is being changed and if new name already exists
|
||||
if (request.ModelName != null && request.ModelName != robotModel.ModelName)
|
||||
{
|
||||
if (await ExistsAsync(request.ModelName))
|
||||
{
|
||||
throw new InvalidOperationException($"Robot model with name '{request.ModelName}' already exists.");
|
||||
}
|
||||
robotModel.ModelName = request.ModelName;
|
||||
}
|
||||
|
||||
if (request.Length.HasValue)
|
||||
robotModel.Length = request.Length.Value;
|
||||
if (request.Width.HasValue)
|
||||
robotModel.Width = request.Width.Value;
|
||||
if (request.ImageWidth.HasValue)
|
||||
robotModel.ImageWidth = request.ImageWidth.Value;
|
||||
if (request.ImageHeight.HasValue)
|
||||
robotModel.ImageHeight = request.ImageHeight.Value;
|
||||
if (request.NavigationPointX.HasValue)
|
||||
robotModel.NavigationPointX = request.NavigationPointX.Value;
|
||||
if (request.NavigationPointY.HasValue)
|
||||
robotModel.NavigationPointY = request.NavigationPointY.Value;
|
||||
if (request.NavigationType.HasValue)
|
||||
robotModel.NavigationType = request.NavigationType.Value;
|
||||
|
||||
// VehicleTypeId: nullable, can be set or cleared
|
||||
// Note: In ASP.NET Core, if VehicleTypeId is in the JSON request, it will be bound
|
||||
// If not in JSON, it remains the default (null for nullable Guid?)
|
||||
// For simplicity, we always update VehicleTypeId if the request contains it
|
||||
// This allows setting to null by sending "VehicleTypeId": null in JSON
|
||||
// To avoid updating when not provided, we'd need a different approach (e.g., use a wrapper DTO)
|
||||
// For now, we'll always update if the property exists in the request object
|
||||
robotModel.VehicleTypeId = request.VehicleTypeId;
|
||||
|
||||
robotModel.UpdatedDate = DateTime.UtcNow;
|
||||
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
_logger.Info($"Updated robot model {robotModel.ModelName} with ID {robotModel.Id}");
|
||||
|
||||
// Publish event for cache invalidation
|
||||
if (_eventBus != null)
|
||||
{
|
||||
try
|
||||
{
|
||||
// Get all robots using this model
|
||||
var affectedRobots = await _context.Robots
|
||||
.Where(r => r.ModelId == robotModel.Id)
|
||||
.Select(r => r.RobotId)
|
||||
.ToListAsync();
|
||||
|
||||
var eventData = new RobotModelUpdatedEvent
|
||||
{
|
||||
ModelId = robotModel.Id,
|
||||
AffectedRobotIds = affectedRobots
|
||||
};
|
||||
|
||||
_eventBus.PublishRobotModelUpdated(eventData);
|
||||
_logger.Debug($"Published RobotModelUpdated event for ModelId {robotModel.Id} affecting {affectedRobots.Count} robot(s)");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Error($"Error publishing RobotModelUpdated event: {ex.Message}");
|
||||
// Don't fail the update if event publishing fails
|
||||
}
|
||||
}
|
||||
|
||||
return robotModel;
|
||||
}
|
||||
|
||||
public async Task<bool> DeleteAsync(Guid id)
|
||||
{
|
||||
var robotModel = await _context.RobotModels
|
||||
.Include(rm => rm.Robots)
|
||||
.FirstOrDefaultAsync(rm => rm.Id == id);
|
||||
|
||||
if (robotModel == null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check if there are any robots using this model
|
||||
if (robotModel.Robots.Count != 0)
|
||||
{
|
||||
throw new InvalidOperationException($"Cannot delete robot model '{robotModel.ModelName}' because it is being used by {robotModel.Robots.Count} robot(s).");
|
||||
}
|
||||
|
||||
_context.RobotModels.Remove(robotModel);
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
_logger.Info($"Deleted robot model {robotModel.ModelName} with ID {robotModel.Id}");
|
||||
return true;
|
||||
}
|
||||
|
||||
public async Task<bool> ExistsAsync(string modelName)
|
||||
{
|
||||
return await _context.RobotModels
|
||||
.AnyAsync(rm => rm.ModelName == modelName);
|
||||
}
|
||||
|
||||
public async Task<List<RobotModel>> SearchAsync(string query)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(query))
|
||||
{
|
||||
return await GetAllAsync();
|
||||
}
|
||||
|
||||
var lowerQuery = query.ToLowerInvariant();
|
||||
return await _context.RobotModels
|
||||
.AsNoTracking()
|
||||
.Where(rm => rm.ModelName.ToLower().Contains(lowerQuery))
|
||||
.OrderBy(rm => rm.ModelName)
|
||||
.ToListAsync();
|
||||
}
|
||||
|
||||
public async Task<RobotModelUsageInfoDto> GetUsageInfoAsync(Guid id)
|
||||
{
|
||||
var robotModel = await _context.RobotModels
|
||||
.AsNoTracking()
|
||||
.Include(rm => rm.Robots)
|
||||
.FirstOrDefaultAsync(rm => rm.Id == id);
|
||||
|
||||
return robotModel == null
|
||||
? throw new KeyNotFoundException($"Robot model with ID {id} not found.")
|
||||
: new RobotModelUsageInfoDto
|
||||
{
|
||||
Id = robotModel.Id,
|
||||
ModelName = robotModel.ModelName,
|
||||
RobotCount = robotModel.Robots.Count
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,222 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using RobotNet10.FleetManager.Data;
|
||||
using RobotNet10.FleetManager.Events;
|
||||
using RobotNet10.FleetManager.Events.Events;
|
||||
using RobotNet10.FleetManager.Shared.DTOs.Robot;
|
||||
|
||||
namespace RobotNet10.FleetManager.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Service implementation for managing robots.
|
||||
/// Handles business logic for CRUD operations on robots.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// This service validates business rules such as duplicate robot IDs,
|
||||
/// ensures referenced robot models exist, and provides filtering and search functionality.
|
||||
/// </remarks>
|
||||
public class RobotService(
|
||||
ApplicationDbContext context,
|
||||
Logger<RobotService> logger,
|
||||
IRobotEventBus? eventBus = null) : IRobotService
|
||||
{
|
||||
private readonly ApplicationDbContext _context = context;
|
||||
private readonly Logger<RobotService> _logger = logger;
|
||||
private readonly IRobotEventBus? _eventBus = eventBus;
|
||||
|
||||
public async Task<Robot> CreateAsync(CreateRobotRequest request)
|
||||
{
|
||||
// Check if robot ID already exists
|
||||
if (await ExistsAsync(request.RobotId))
|
||||
{
|
||||
throw new InvalidOperationException($"Robot with ID '{request.RobotId}' already exists.");
|
||||
}
|
||||
|
||||
// Verify that the model exists
|
||||
var modelExists = await _context.RobotModels.AnyAsync(rm => rm.Id == request.ModelId);
|
||||
if (!modelExists)
|
||||
{
|
||||
throw new KeyNotFoundException($"Robot model with ID {request.ModelId} not found.");
|
||||
}
|
||||
|
||||
var robot = new Robot
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
RobotId = request.RobotId,
|
||||
Name = request.Name,
|
||||
ModelId = request.ModelId,
|
||||
MapId = request.MapId,
|
||||
CreatedDate = DateTime.UtcNow
|
||||
};
|
||||
|
||||
_context.Robots.Add(robot);
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
_logger.Info($"Created robot {robot.Name} with RobotId {robot.RobotId} and ID {robot.Id}");
|
||||
return robot;
|
||||
}
|
||||
|
||||
public async Task<List<Robot>> GetAllAsync(Guid? modelId = null, Guid? mapId = null)
|
||||
{
|
||||
var query = _context.Robots.AsNoTracking().Include(r => r.Model).AsQueryable();
|
||||
|
||||
if (modelId.HasValue)
|
||||
{
|
||||
query = query.Where(r => r.ModelId == modelId.Value);
|
||||
}
|
||||
|
||||
if (mapId.HasValue)
|
||||
{
|
||||
query = query.Where(r => r.MapId == mapId.Value);
|
||||
}
|
||||
|
||||
return await query
|
||||
.OrderBy(r => r.Name)
|
||||
.ToListAsync();
|
||||
}
|
||||
|
||||
public async Task<Robot?> GetByIdAsync(Guid id)
|
||||
{
|
||||
return await _context.Robots
|
||||
.AsNoTracking()
|
||||
.Include(r => r.Model)
|
||||
.FirstOrDefaultAsync(r => r.Id == id);
|
||||
}
|
||||
|
||||
public async Task<Robot?> GetByRobotIdAsync(string robotId)
|
||||
{
|
||||
return await _context.Robots
|
||||
.AsNoTracking()
|
||||
.Include(r => r.Model)
|
||||
.FirstOrDefaultAsync(r => r.RobotId == robotId);
|
||||
}
|
||||
|
||||
public async Task<Robot> UpdateAsync(Guid id, UpdateRobotRequest request)
|
||||
{
|
||||
var robot = await _context.Robots.FindAsync(id) ?? throw new KeyNotFoundException($"Robot with ID {id} not found.");
|
||||
|
||||
// Check if robot ID is being changed and if new ID already exists
|
||||
if (request.RobotId != null && request.RobotId != robot.RobotId)
|
||||
{
|
||||
if (await ExistsAsync(request.RobotId))
|
||||
{
|
||||
throw new InvalidOperationException($"Robot with ID '{request.RobotId}' already exists.");
|
||||
}
|
||||
robot.RobotId = request.RobotId;
|
||||
}
|
||||
|
||||
if (request.Name != null)
|
||||
robot.Name = request.Name;
|
||||
|
||||
if (request.ModelId.HasValue)
|
||||
{
|
||||
// Verify that the model exists
|
||||
var modelExists = await _context.RobotModels.AnyAsync(rm => rm.Id == request.ModelId.Value);
|
||||
if (!modelExists)
|
||||
{
|
||||
throw new KeyNotFoundException($"Robot model with ID {request.ModelId.Value} not found.");
|
||||
}
|
||||
|
||||
// Check if ModelId is actually changing
|
||||
var previousModelId = robot.ModelId;
|
||||
if (previousModelId != request.ModelId.Value)
|
||||
{
|
||||
robot.ModelId = request.ModelId.Value;
|
||||
|
||||
// Publish event for cache invalidation
|
||||
if (_eventBus != null)
|
||||
{
|
||||
try
|
||||
{
|
||||
var eventData = new RobotModelIdChangedEvent
|
||||
{
|
||||
RobotId = robot.RobotId,
|
||||
PreviousModelId = previousModelId,
|
||||
NewModelId = request.ModelId.Value
|
||||
};
|
||||
|
||||
_eventBus.PublishRobotModelIdChanged(eventData);
|
||||
_logger.Debug($"Published RobotModelIdChanged event for robot {robot.RobotId} (from {previousModelId} to {request.ModelId.Value})");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Error($"Error publishing RobotModelIdChanged event: {ex.Message}");
|
||||
// Don't fail the update if event publishing fails
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MapId: if provided (including null), update it
|
||||
// Note: In C#, nullable Guid? means: HasValue = true means a value was provided (could be Guid.Empty or a valid Guid)
|
||||
// To distinguish between "not provided" and "explicitly set to null", we'd need a different approach
|
||||
// For now, we'll only update MapId if it has a value (non-null)
|
||||
if (request.MapId.HasValue)
|
||||
{
|
||||
robot.MapId = request.MapId.Value;
|
||||
}
|
||||
|
||||
robot.UpdatedDate = DateTime.UtcNow;
|
||||
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
_logger.Info($"Updated robot {robot.Name} with RobotId {robot.RobotId} and ID {robot.Id}");
|
||||
return robot;
|
||||
}
|
||||
|
||||
public async Task<bool> DeleteAsync(Guid id)
|
||||
{
|
||||
var robot = await _context.Robots.FindAsync(id);
|
||||
if (robot == null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
_context.Robots.Remove(robot);
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
_logger.Info($"Deleted robot {robot.Name} with RobotId {robot.RobotId} and ID {robot.Id}");
|
||||
return true;
|
||||
}
|
||||
|
||||
public async Task<List<Robot>> GetByModelIdAsync(Guid modelId)
|
||||
{
|
||||
return await _context.Robots
|
||||
.AsNoTracking()
|
||||
.Include(r => r.Model)
|
||||
.Where(r => r.ModelId == modelId)
|
||||
.OrderBy(r => r.Name)
|
||||
.ToListAsync();
|
||||
}
|
||||
|
||||
public async Task<List<Robot>> SearchAsync(string query)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(query))
|
||||
{
|
||||
return await GetAllAsync();
|
||||
}
|
||||
|
||||
var lowerQuery = query.ToLowerInvariant();
|
||||
return await _context.Robots
|
||||
.AsNoTracking()
|
||||
.Include(r => r.Model)
|
||||
.Where(r => r.RobotId.ToLower().Contains(lowerQuery) || r.Name.ToLower().Contains(lowerQuery))
|
||||
.OrderBy(r => r.Name)
|
||||
.ToListAsync();
|
||||
}
|
||||
|
||||
public async Task<bool> ExistsAsync(string robotId)
|
||||
{
|
||||
return await _context.Robots
|
||||
.AnyAsync(r => r.RobotId == robotId);
|
||||
}
|
||||
|
||||
public async Task<List<Robot>> GetByModelNameAsync(string modelName)
|
||||
{
|
||||
return await _context.Robots
|
||||
.AsNoTracking()
|
||||
.Include(r => r.Model)
|
||||
.Where(r => r.Name == modelName)
|
||||
.OrderBy(r => r.Name)
|
||||
.ToListAsync();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
using RobotNet10.FleetManager.Script;
|
||||
|
||||
namespace RobotNet10.FleetManager.Services.Script;
|
||||
|
||||
public class ScriptLayoutManager : ILayoutManager
|
||||
{
|
||||
public Task<RobotNet.VDA5050.InstantAction.Action> GetAction(string layout, string version, string level, string name, string robotId)
|
||||
{
|
||||
throw new NotImplementedException();
|
||||
}
|
||||
|
||||
public Task<INode> GetNode(string layout, string version, string level, string name)
|
||||
{
|
||||
throw new NotImplementedException();
|
||||
}
|
||||
|
||||
public Task<IStation> GetStation(string layout, string version, string level, string name)
|
||||
{
|
||||
throw new NotImplementedException();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
using RobotNet10.FleetManager.Script;
|
||||
using RobotNet10.FleetManager.Services.RobotController;
|
||||
|
||||
namespace RobotNet10.FleetManager.Services.Script;
|
||||
|
||||
public class ScriptRobot(string robotId, string robotName, Guid modelId, Guid? mapId, IRobotController robotController) : IRobot
|
||||
{
|
||||
public string RobotId { get; } = robotId;
|
||||
|
||||
public string Name { get; } = robotName;
|
||||
|
||||
public Guid ModelId { get; } = modelId;
|
||||
|
||||
public Guid? MapId { get; } = mapId;
|
||||
|
||||
public RobotState State => new( robotController.IsReady,
|
||||
robotController.Data.State?.BatteryState.BatteryVoltage ?? 0,
|
||||
robotController.Data.State?.Loads ?? [],
|
||||
robotController.Data.State?.BatteryState.Charging ?? false,
|
||||
robotController.Data.Visualization?.AgvPosition.X ?? 0,
|
||||
robotController.Data.Visualization?.AgvPosition.Y ?? 0,
|
||||
robotController.Data.Visualization?.AgvPosition.Theta ?? 0);
|
||||
|
||||
public Task AbortMovement()
|
||||
{
|
||||
throw new NotImplementedException();
|
||||
}
|
||||
|
||||
public async Task<RobotResult> Execute(RobotNet.VDA5050.InstantAction.Action action, CancellationToken cancellationToken)
|
||||
{
|
||||
var result = await robotController.SendInstantActionAsync(action, cancellationToken);
|
||||
return new(result.IsSuccess, result.Message);
|
||||
}
|
||||
|
||||
public async Task<RobotResult> MoveToNode(string nodeName, CancellationToken cancellationToken)
|
||||
{
|
||||
var result = await robotController.MoveToNodeAsync(nodeName, null, cancellationToken);
|
||||
return new(result.IsSuccess, result.Message);
|
||||
}
|
||||
|
||||
public async Task<RobotResult> MoveToNode(string nodeName, double lastAngle, CancellationToken cancellationToken)
|
||||
{
|
||||
var result = await robotController.MoveToNodeAsync(nodeName, lastAngle, cancellationToken);
|
||||
return new(result.IsSuccess, result.Message);
|
||||
}
|
||||
|
||||
public async Task<RobotResult> MoveToStation(string stationName, StationAction action, CancellationToken cancellationToken)
|
||||
{
|
||||
var result = await robotController.MoveToStationAsync(stationName, action, cancellationToken);
|
||||
return new(result.IsSuccess, result.Message);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
using RobotNet10.FleetManager.Script;
|
||||
using RobotNet10.FleetManager.Services.RobotManager;
|
||||
using RobotNet10.FleetManager.Services.TrafficControl;
|
||||
using System.Linq.Expressions;
|
||||
|
||||
namespace RobotNet10.FleetManager.Services.Script;
|
||||
|
||||
public class ScriptRobotmanager(IRobotManagerService RobotManager, IServiceScopeFactory ScopeFactory, IOrderControlService OrderControlService) : IRobotManager
|
||||
{
|
||||
public async Task<IRobot?> GetRobotById(string robotId)
|
||||
{
|
||||
using var scope = ScopeFactory.CreateScope();
|
||||
var _robotService = scope.ServiceProvider.GetRequiredService<IRobotService>();
|
||||
var robotDb = await _robotService.GetByRobotIdAsync(robotId);
|
||||
if (robotDb is null) return null;
|
||||
var robotController = RobotManager.GetRobotController(robotId);
|
||||
if (robotController is null) return null;
|
||||
return new ScriptRobot(robotDb.RobotId, robotDb.Name, robotDb.ModelId, robotDb.MapId, robotController);
|
||||
}
|
||||
|
||||
public Task<RobotOrderStatus> GetRobotOrderStatus(string robotId)
|
||||
{
|
||||
var orderStatus = OrderControlService.GetRobotOrderStatus(robotId);
|
||||
return orderStatus switch
|
||||
{
|
||||
OrderStatus.IsError => Task.FromResult(RobotOrderStatus.IsError),
|
||||
OrderStatus.IsCompleted => Task.FromResult(RobotOrderStatus.IsCompleted),
|
||||
OrderStatus.IsProccessing => Task.FromResult(RobotOrderStatus.IsProccessing),
|
||||
OrderStatus.IsCanceled => Task.FromResult(RobotOrderStatus.IsCanceled),
|
||||
_ => Task.FromResult(RobotOrderStatus.Empty),
|
||||
};
|
||||
}
|
||||
|
||||
public Task<RobotState?> GetRobotState(string robotId)
|
||||
{
|
||||
var robotController = RobotManager.GetRobotController(robotId);
|
||||
if (robotController is null || robotController.Data is null || robotController.Data.State is null || robotController.Data.Visualization is null) return Task.FromResult<RobotState?>(null);
|
||||
return Task.FromResult<RobotState?>(new RobotState(robotController.IsReady,
|
||||
robotController.Data.State.BatteryState.BatteryVoltage ?? 0,
|
||||
robotController.Data.State.Loads,
|
||||
robotController.Data.State.BatteryState.Charging,
|
||||
robotController.Data.Visualization.AgvPosition.X,
|
||||
robotController.Data.Visualization.AgvPosition.Y,
|
||||
robotController.Data.Visualization.AgvPosition.Theta));
|
||||
}
|
||||
|
||||
public async Task<IEnumerable<string>> SearchRobots(string layout, string version, string level, string model)
|
||||
=> await RobotManager.GetAvailableRobots(layout, version, level, model, state => true);
|
||||
|
||||
public async Task<IEnumerable<string>> SearchRobots(string layout, string version, string level, string model, Expression<Func<RobotState, bool>> expr)
|
||||
=> await RobotManager.GetAvailableRobots(layout, version, level, model, expr.Compile());
|
||||
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
using RobotNet10.FleetManager.Models;
|
||||
using RobotNet10.FleetManager.Script;
|
||||
using RobotNet10.FleetManager.Script.Shared;
|
||||
using RobotNet10.ScriptEngine.Helpers;
|
||||
using RobotNet10.ScriptEngine.Shared;
|
||||
using System.Collections.Immutable;
|
||||
|
||||
namespace RobotNet10.FleetManager.Services;
|
||||
|
||||
public class ScriptEngineResource(IRobotManager robotManager, ILayoutManager layoutManager) : IScriptEngineResource
|
||||
{
|
||||
public Type AppGlobalType => FleetManagerScriptEngineResource.GlobalType;
|
||||
|
||||
public ImmutableArray<string> UsingNamespaces => FleetManagerScriptEngineResource.UsingNamespaces;
|
||||
|
||||
public ImmutableArray<string> Modules => FleetManagerScriptEngineResource.Modules;
|
||||
|
||||
public ImmutableArray<string> DocModules => FleetManagerScriptEngineResource.DocModules;
|
||||
|
||||
public IDictionary<string, object?> GetMissionGlobals(Guid id, CancellationToken cancellationToken)
|
||||
{
|
||||
var globals = new FleetManagerScriptGlobals(robotManager, layoutManager);
|
||||
return ScriptHelper.ConvertGlobalsToDictionary(globals, typeof(IFleetManagerScriptGlobals));
|
||||
}
|
||||
|
||||
public IDictionary<string, object?> GetTaskGlobals()
|
||||
{
|
||||
var globals = new FleetManagerScriptGlobals(robotManager, layoutManager);
|
||||
return ScriptHelper.ConvertGlobalsToDictionary(globals, typeof(IFleetManagerScriptGlobals));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,923 @@
|
||||
using RobotNet.VDA5050.Order;
|
||||
using RobotNet.VDA5050.State;
|
||||
using RobotNet10.Common;
|
||||
using RobotNet10.FleetManager.Data;
|
||||
using RobotNet10.FleetManager.Services.ConfigManager;
|
||||
using RobotNet10.FleetManager.Services.OpenACS;
|
||||
using RobotNet10.FleetManager.Services.RobotController;
|
||||
using RobotNet10.FleetManager.Services.RobotManager;
|
||||
using RobotNet10.FleetManager.Services.TrafficControl.Models;
|
||||
using RobotNet10.MapManager.Services;
|
||||
using System.Collections.Concurrent;
|
||||
|
||||
namespace RobotNet10.FleetManager.Services.TrafficControl.ACS;
|
||||
|
||||
public class OrderACSControl : IOrderControlService, IDisposable
|
||||
{
|
||||
private readonly IServiceScopeFactory _serviceScopeFactory;
|
||||
private readonly IACSTrafficConfig _acsTrafficConfig;
|
||||
private readonly TrafficACS _trafficACS;
|
||||
private readonly Logger<OrderACSControl> _logger;
|
||||
private readonly ILogger<OrderACSControl> _loggerTimer;
|
||||
|
||||
// Store order state per robot
|
||||
private readonly ConcurrentDictionary<string, OrderACSState> _orderStates = new();
|
||||
|
||||
private WatchTimerAsync<OrderACSControl>? _processingTimer;
|
||||
private readonly Lock _timerLock = new();
|
||||
private bool _disposed = false;
|
||||
|
||||
public OrderACSControl(
|
||||
IServiceScopeFactory serviceScopeFactory,
|
||||
IACSTrafficConfig acsTrafficConfig,
|
||||
TrafficACS trafficACS,
|
||||
Logger<OrderACSControl> logger,
|
||||
ILoggerFactory loggerFactory)
|
||||
{
|
||||
_serviceScopeFactory = serviceScopeFactory ?? throw new ArgumentNullException(nameof(serviceScopeFactory));
|
||||
_acsTrafficConfig = acsTrafficConfig ?? throw new ArgumentNullException(nameof(acsTrafficConfig));
|
||||
_trafficACS = trafficACS ?? throw new ArgumentNullException(nameof(trafficACS));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_loggerTimer = loggerFactory?.CreateLogger<OrderACSControl>() ?? throw new ArgumentNullException(nameof(loggerFactory));
|
||||
|
||||
// Subscribe to config changes
|
||||
_acsTrafficConfig.ConfigChanged += OnConfigChanged;
|
||||
|
||||
_logger.Info($"Started OrderACSControl processing timer with interval {_acsTrafficConfig.TrafficInterval}ms");
|
||||
}
|
||||
|
||||
private void OnConfigChanged(object? sender, EventArgs e)
|
||||
{
|
||||
try
|
||||
{
|
||||
var newInterval = _acsTrafficConfig.TrafficInterval;
|
||||
if (newInterval == _processingTimer?.Interval) return;
|
||||
_logger.Info($"ACSTrafficConfig changed, updating timer interval to {newInterval}ms");
|
||||
|
||||
lock (_timerLock)
|
||||
{
|
||||
// Stop old timer
|
||||
_processingTimer?.Stop();
|
||||
_processingTimer?.Dispose();
|
||||
|
||||
// Start new timer with new interval
|
||||
StartProcessingTimer();
|
||||
}
|
||||
|
||||
_logger.Info($"OrderACSControl processing timer updated to interval {newInterval}ms");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Error($"Error updating timer interval: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private void StartProcessingTimer()
|
||||
{
|
||||
var interval = _acsTrafficConfig.TrafficInterval;
|
||||
_processingTimer = new WatchTimerAsync<OrderACSControl>(
|
||||
interval,
|
||||
ProcessAllOrdersAsync,
|
||||
_loggerTimer
|
||||
);
|
||||
_processingTimer.Start();
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (_disposed) return;
|
||||
|
||||
// Unsubscribe from config changes
|
||||
_acsTrafficConfig.ConfigChanged -= OnConfigChanged;
|
||||
|
||||
lock (_timerLock)
|
||||
{
|
||||
_processingTimer?.Stop();
|
||||
_processingTimer?.Dispose();
|
||||
}
|
||||
|
||||
_disposed = true;
|
||||
_logger.Info("Stopped OrderACSControl processing timer");
|
||||
GC.SuppressFinalize(this);
|
||||
|
||||
}
|
||||
|
||||
private async Task ProcessAllOrdersAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
// Process all active orders
|
||||
foreach (var kvp in _orderStates)
|
||||
{
|
||||
var robotId = kvp.Key;
|
||||
var orderState = kvp.Value;
|
||||
|
||||
if (orderState.Status != OrderStatus.IsProccessing)
|
||||
{
|
||||
// Skip non-processing orders
|
||||
continue;
|
||||
}
|
||||
|
||||
// Get robot controller and data using service scope to avoid circular dependency
|
||||
IRobotController? robotController = null;
|
||||
try
|
||||
{
|
||||
using var scope = _serviceScopeFactory.CreateScope();
|
||||
var robotManagerService = scope.ServiceProvider.GetRequiredService<IRobotManagerService>();
|
||||
robotController = robotManagerService.GetRobotController(robotId);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Error($"ProcessAllOrdersAsync: Error getting robot controller for {robotId}: {ex.Message}");
|
||||
continue;
|
||||
}
|
||||
|
||||
if (robotController == null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check if robot is online
|
||||
if (!robotController.IsOnline)
|
||||
{
|
||||
// Check if robot has been offline for more than 1 minute
|
||||
var offlineDuration = DateTime.UtcNow - orderState.LastUpdated;
|
||||
if (offlineDuration.TotalMinutes > 1)
|
||||
{
|
||||
orderState.Status = OrderStatus.IsError;
|
||||
orderState.Error = $"Robot {robotId} has been offline for more than 1 minute";
|
||||
orderState.LastUpdated = DateTime.UtcNow;
|
||||
_logger.Warning($"CheckRobotOnline: Order for robot {robotId} marked as Error due to offline timeout ({offlineDuration.TotalMinutes:F1} minutes)");
|
||||
continue;
|
||||
}
|
||||
// Robot is offline but less than 1 minute, skip processing
|
||||
continue;
|
||||
}
|
||||
|
||||
var robotData = robotController.Data;
|
||||
var stateMsg = robotData?.State;
|
||||
|
||||
if (stateMsg == null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
// Update last updated time
|
||||
orderState.LastUpdated = DateTime.UtcNow;
|
||||
|
||||
// Check if robot has reached a mapped node
|
||||
var currentLastNodeId = stateMsg.LastNodeId;
|
||||
await CheckAndProcessMappedNodesAsync(robotId, currentLastNodeId, orderState, stateMsg);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Error($"ProcessAllOrdersAsync: Error processing orders: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
public OrderStatus GetRobotOrderStatus(string robotId)
|
||||
{
|
||||
if (_orderStates.TryGetValue(robotId, out var state))
|
||||
{
|
||||
return state.Status;
|
||||
}
|
||||
return OrderStatus.Empty; // No order found
|
||||
}
|
||||
|
||||
public async Task<bool> CreateRobotOrderAsync(string robotId, RobotRoute route)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (string.IsNullOrEmpty(robotId))
|
||||
{
|
||||
_logger.Warning("CreateRobotOrderAsync: robotId is null or empty");
|
||||
return false;
|
||||
}
|
||||
|
||||
if (route == null || route.FullRoute == null || route.FullRoute.Count == 0)
|
||||
{
|
||||
_logger.Warning($"CreateRobotOrderAsync: Invalid route for robot {robotId}");
|
||||
return false;
|
||||
}
|
||||
|
||||
// Find all nodes that are mapped to ACS zones (IN and OUT separately)
|
||||
var (inMappedNodes, outMappedNodes) = await FindMappedNodesAsync(route);
|
||||
|
||||
// Find first IN mapped node index (only IN nodes affect Base/Horizon calculation)
|
||||
var firstMappedNodeIndex = FindFirstMappedNodeIndex(route, inMappedNodes);
|
||||
|
||||
int baseSegmentCount;
|
||||
if (inMappedNodes.Count == 0 || firstMappedNodeIndex < 0)
|
||||
{
|
||||
// No IN mapped nodes, base = full route
|
||||
baseSegmentCount = route.FullRoute.Count;
|
||||
_logger.Info($"CreateRobotOrderAsync: No IN mapped nodes found for robot {robotId}, base = full route");
|
||||
}
|
||||
else
|
||||
{
|
||||
// Calculate Base: segments from start to first mapped node + 1 segment
|
||||
baseSegmentCount = firstMappedNodeIndex + 3;
|
||||
if (baseSegmentCount > route.FullRoute.Count)
|
||||
{
|
||||
baseSegmentCount = route.FullRoute.Count;
|
||||
}
|
||||
}
|
||||
|
||||
// Split route into Base and Horizon
|
||||
route.Base = [.. route.FullRoute.Take(baseSegmentCount)];
|
||||
route.Horizon = [.. route.FullRoute.Skip(baseSegmentCount)];
|
||||
|
||||
// Mark base segments as released
|
||||
foreach (var segment in route.Base)
|
||||
{
|
||||
segment.Released = true;
|
||||
}
|
||||
|
||||
_logger.Info($"CreateRobotOrderAsync: Order created for robot {robotId}, Base segments: {route.Base.Count}, Horizon segments: {route.Horizon.Count}, IN mapped nodes: {inMappedNodes.Count}, OUT mapped nodes: {outMappedNodes.Count}");
|
||||
|
||||
// Send order to robot
|
||||
var send = await SendOrderToRobotAsync(robotId, route, isInitial: true);
|
||||
if (send)
|
||||
{
|
||||
// Create order state
|
||||
var orderState = new OrderACSState
|
||||
{
|
||||
RobotId = robotId,
|
||||
Status = OrderStatus.IsProccessing,
|
||||
Route = route,
|
||||
InMappedNodes = inMappedNodes,
|
||||
OutMappedNodes = outMappedNodes,
|
||||
CurrentInMappedNodeIndex = 0,
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
LastUpdated = DateTime.UtcNow,
|
||||
ZoneRequestInCompleting = [],
|
||||
ZoneRequestOutCompleting = [],
|
||||
};
|
||||
|
||||
_orderStates.AddOrUpdate(robotId, orderState, (key, old) => orderState);
|
||||
|
||||
}
|
||||
|
||||
return send;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Error($"CreateRobotOrderAsync: Error creating order for robot {robotId}: {ex.Message}");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<(List<(string NodeIdString, string ZoneId)> InMappedNodes, List<(string NodeIdString, string ZoneId)> OutMappedNodes)> FindMappedNodesAsync(RobotRoute route)
|
||||
{
|
||||
var inMappedNodes = new List<(string NodeIdString, string ZoneId)>();
|
||||
var outMappedNodes = new List<(string NodeIdString, string ZoneId)>();
|
||||
var acsZoneMapping = _acsTrafficConfig.ACSZoneMaping;
|
||||
var acsOutMapping = _acsTrafficConfig.ACSOutMaping;
|
||||
|
||||
// Get all unique NodeIds (Guid) from route segments
|
||||
var nodeIds = route.FullRoute
|
||||
.Where(s => s.VdaNode != null)
|
||||
.Select(s => s.NodeId)
|
||||
.Distinct()
|
||||
.ToList();
|
||||
|
||||
if (nodeIds.Count == 0)
|
||||
{
|
||||
return (inMappedNodes, outMappedNodes);
|
||||
}
|
||||
|
||||
// Create mapping: NodeId (Guid) -> NodeName
|
||||
var nodeIdToNodeName = new Dictionary<Guid, string?>();
|
||||
try
|
||||
{
|
||||
using var scope = _serviceScopeFactory.CreateScope();
|
||||
var nodeService = scope.ServiceProvider.GetRequiredService<INodeService>();
|
||||
|
||||
foreach (var nodeId in nodeIds)
|
||||
{
|
||||
var node = await nodeService.GetByIdAsync(nodeId, includeVehicleProperties: false);
|
||||
if (node != null && !string.IsNullOrEmpty(node.NodeName))
|
||||
{
|
||||
nodeIdToNodeName[nodeId] = node.NodeName;
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Error($"FindMappedNodesAsync: Error getting NodeName from database: {ex.Message}");
|
||||
// Continue with empty mapping - will skip nodes without NodeName
|
||||
}
|
||||
|
||||
foreach (var segment in route.FullRoute)
|
||||
{
|
||||
if (segment.VdaNode != null)
|
||||
{
|
||||
var nodeIdString = segment.VdaNode.NodeId;
|
||||
|
||||
// Get NodeName from mapping (using NodeId Guid)
|
||||
if (!nodeIdToNodeName.TryGetValue(segment.NodeId, out var nodeName) || string.IsNullOrEmpty(nodeName))
|
||||
{
|
||||
// Skip if NodeName not found
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check if node is in ACSZoneMapping (RequestIn) using NodeName
|
||||
if (acsZoneMapping.TryGetValue(nodeName, out var zoneId))
|
||||
{
|
||||
inMappedNodes.Add((nodeIdString, zoneId));
|
||||
}
|
||||
|
||||
// Check if node is in ACSOutMapping (RequestOut) using NodeName
|
||||
if (acsOutMapping.TryGetValue(nodeName, out var outZoneId))
|
||||
{
|
||||
outMappedNodes.Add((nodeIdString, outZoneId));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (inMappedNodes, outMappedNodes);
|
||||
}
|
||||
|
||||
private static int FindFirstMappedNodeIndex(RobotRoute route, List<(string NodeIdString, string ZoneId)> mappedNodes)
|
||||
{
|
||||
if (mappedNodes.Count == 0) return -1;
|
||||
|
||||
(string NodeIdString, _) = mappedNodes[0];
|
||||
|
||||
for (int i = 0; i < route.FullRoute.Count; i++)
|
||||
{
|
||||
var segment = route.FullRoute[i];
|
||||
if (segment.VdaNode != null && segment.VdaNode.NodeId == NodeIdString)
|
||||
{
|
||||
return i;
|
||||
}
|
||||
}
|
||||
|
||||
return -1;
|
||||
}
|
||||
|
||||
private async Task CheckAndProcessMappedNodesAsync(string robotId, string lastNodeId, OrderACSState orderState, StateMsg stateMsg)
|
||||
{
|
||||
try
|
||||
{
|
||||
foreach (var (NodeIdString, ZoneId) in orderState.OutMappedNodes)
|
||||
{
|
||||
if (lastNodeId == NodeIdString && !string.IsNullOrEmpty(ZoneId) && !orderState.ZoneRequestOutCompleting.Contains(ZoneId) && !orderState.ZoneRequestOutCompleted.Contains(ZoneId))
|
||||
{
|
||||
orderState.ZoneRequestOutCompleting.Add(ZoneId);
|
||||
}
|
||||
}
|
||||
var requestOut = ProcessRequestOutAsync(robotId, orderState);
|
||||
|
||||
foreach (var (NodeIdString, ZoneId) in orderState.InMappedNodes)
|
||||
{
|
||||
if (lastNodeId == NodeIdString && !string.IsNullOrEmpty(ZoneId) && !orderState.ZoneRequestInCompleting.Contains(ZoneId) && !orderState.ZoneRequestInCompleted.Contains(ZoneId))
|
||||
{
|
||||
orderState.ZoneRequestInCompleting.Add(ZoneId);
|
||||
}
|
||||
}
|
||||
await ProcessRequestInAsync(robotId, orderState);
|
||||
|
||||
await requestOut.WaitAsync(CancellationToken.None);
|
||||
|
||||
// Check if order is completed
|
||||
CheckOrderCompletion(robotId, orderState, stateMsg);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Error($"CheckAndProcessMappedNodes: Error for robot {robotId}: {ex.Message}");
|
||||
orderState.Status = OrderStatus.IsError;
|
||||
orderState.Error = ex.Message;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task ProcessRequestInAsync(string robotId, OrderACSState orderState)
|
||||
{
|
||||
string[] zoneIdsIn = [.. orderState.ZoneRequestInCompleting];
|
||||
if (zoneIdsIn.Length == 0) return;
|
||||
foreach (var zoneId in zoneIdsIn)
|
||||
{
|
||||
try
|
||||
{
|
||||
|
||||
if (orderState.ZoneRequestInCompleted.Contains(zoneId)) continue;
|
||||
_logger.Info($"ProcessRequestInAsync: Robot {robotId} requesting into zone {zoneId}");
|
||||
|
||||
var result = await _trafficACS.RequestIn(robotId, zoneId);
|
||||
|
||||
if (result.IsSuccess && result.Data)
|
||||
{
|
||||
// RequestIn successful - save zone to cache for future RequestOut validation
|
||||
orderState.ZoneRequestInCompleted.Add(zoneId);
|
||||
orderState.ZoneRequestInCompleting.Remove(zoneId);
|
||||
orderState.ZoneRequestOutCompleted.Remove(zoneId);
|
||||
orderState.LastUpdated = DateTime.UtcNow;
|
||||
|
||||
_logger.Info($"ProcessRequestInAsync: Robot {robotId} successfully requested into zone {zoneId}");
|
||||
}
|
||||
else
|
||||
{
|
||||
// RequestIn failed, just log warning - will retry on next timer cycle
|
||||
_logger.Warning($"ProcessRequestInAsync: Robot {robotId} failed to request into zone {zoneId}: {result.Message}. Will retry on next cycle.");
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Error($"ProcessRequestInAsync: Error for robot {robotId}, zone {zoneId}: {ex.Message}. Will retry on next cycle.");
|
||||
}
|
||||
}
|
||||
if (orderState.ZoneRequestInCompleting.Count == 0)
|
||||
{
|
||||
orderState.CurrentInMappedNodeIndex++;
|
||||
await TryReleaseNextHorizonSegmentAsync(robotId, orderState);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task ProcessRequestOutAsync(string robotId, OrderACSState orderState)
|
||||
{
|
||||
string[] zoneIdsOut = [.. orderState.ZoneRequestOutCompleting];
|
||||
if (zoneIdsOut.Length == 0) return;
|
||||
foreach (var zoneId in zoneIdsOut)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (orderState.ZoneRequestOutCompleted.Contains(zoneId)) continue;
|
||||
_logger.Info($"ProcessRequestOutAsync: Robot {robotId} requesting out of zone {zoneId}");
|
||||
|
||||
var result = await _trafficACS.RequestOut(robotId, zoneId);
|
||||
|
||||
if (result.IsSuccess && result.Data)
|
||||
{
|
||||
orderState.LastUpdated = DateTime.UtcNow;
|
||||
orderState.ZoneRequestOutCompleted.Add(zoneId);
|
||||
orderState.ZoneRequestOutCompleting.Remove(zoneId);
|
||||
orderState.ZoneRequestInCompleted.Remove(zoneId);
|
||||
|
||||
_logger.Info($"ProcessRequestOutAsync: Robot {robotId} successfully requested out of zone {zoneId}");
|
||||
// Note: OUT does NOT affect Base/Horizon, so we don't call TryReleaseNextHorizonSegmentAsync
|
||||
}
|
||||
else
|
||||
{
|
||||
// RequestOut failed - will retry on next timer cycle until successful
|
||||
// OUT must retry until successful (unlike IN which can be skipped)
|
||||
_logger.Warning($"ProcessRequestOutAsync: Robot {robotId} failed to request out of zone {zoneId}: {result.Message}. Will retry on next cycle until successful.");
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// RequestOut error - will retry on next timer cycle until successful
|
||||
_logger.Error($"ProcessRequestOutAsync: Error for robot {robotId}, zone {zoneId}: {ex.Message}. Will retry on next cycle until successful.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async Task TryReleaseNextHorizonSegmentAsync(string robotId, OrderACSState orderState)
|
||||
{
|
||||
try
|
||||
{
|
||||
bool horizonUpdated = false;
|
||||
int baseCountBeforeRelease = orderState.Route.Base.Count; // Store base count before release
|
||||
|
||||
// Check if there are more IN mapped nodes to process (only IN affects Base/Horizon)
|
||||
if (orderState.CurrentInMappedNodeIndex >= orderState.InMappedNodes.Count)
|
||||
{
|
||||
// All mapped nodes processed, check if we can release all remaining horizon
|
||||
if (orderState.Route.Horizon.Count > 0)
|
||||
{
|
||||
// Move all remaining horizon to base
|
||||
var segmentsToMove = orderState.Route.Horizon.ToList();
|
||||
orderState.Route.Base.AddRange(segmentsToMove);
|
||||
orderState.Route.Horizon.Clear();
|
||||
|
||||
// Only set Released = true for newly released segments
|
||||
foreach (var segment in segmentsToMove)
|
||||
{
|
||||
segment.Released = true;
|
||||
}
|
||||
|
||||
orderState.LastUpdated = DateTime.UtcNow;
|
||||
horizonUpdated = true;
|
||||
_logger.Info($"TryReleaseNextHorizonSegmentAsync: Released all remaining horizon segments for robot {robotId}");
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
// Find next IN mapped node
|
||||
var (NodeIdString, _) = orderState.InMappedNodes[orderState.CurrentInMappedNodeIndex];
|
||||
|
||||
// Find index of next mapped node in full route
|
||||
int nextMappedNodeIndex = -1;
|
||||
for (int i = 0; i < orderState.Route.FullRoute.Count; i++)
|
||||
{
|
||||
var segment = orderState.Route.FullRoute[i];
|
||||
if (segment.VdaNode != null && segment.VdaNode.NodeId == NodeIdString)
|
||||
{
|
||||
nextMappedNodeIndex = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (nextMappedNodeIndex >= 0)
|
||||
{
|
||||
// Calculate how many segments to release: from current base end to next mapped node + 1
|
||||
var currentBaseEndIndex = orderState.Route.Base.Count;
|
||||
var segmentsToRelease = nextMappedNodeIndex - currentBaseEndIndex + 3;
|
||||
|
||||
if (segmentsToRelease > 0 && segmentsToRelease <= orderState.Route.Horizon.Count)
|
||||
{
|
||||
// Release segments to base
|
||||
var segmentsToMove = orderState.Route.Horizon.Take(segmentsToRelease).ToList();
|
||||
orderState.Route.Base.AddRange(segmentsToMove);
|
||||
orderState.Route.Horizon.RemoveRange(0, segmentsToRelease);
|
||||
|
||||
foreach (var segment in segmentsToMove)
|
||||
{
|
||||
segment.Released = true;
|
||||
}
|
||||
|
||||
orderState.LastUpdated = DateTime.UtcNow;
|
||||
horizonUpdated = true;
|
||||
_logger.Info($"TryReleaseNextHorizonSegmentAsync: Released {segmentsToRelease} segments to base for robot {robotId}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If horizon was updated, send OrderUpdate to robot
|
||||
if (horizonUpdated)
|
||||
{
|
||||
await SendOrderToRobotAsync(robotId, orderState.Route, isInitial: false, baseCountBeforeRelease);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Error($"TryReleaseNextHorizonSegmentAsync: Error for robot {robotId}: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<bool> SendOrderToRobotAsync(string robotId, RobotRoute route, bool isInitial, int baseCountBeforeRelease = -1)
|
||||
{
|
||||
try
|
||||
{
|
||||
// Get robot controller using service scope to avoid circular dependency
|
||||
IRobotController? robotController = null;
|
||||
try
|
||||
{
|
||||
using var scope = _serviceScopeFactory.CreateScope();
|
||||
var robotManagerService = scope.ServiceProvider.GetRequiredService<IRobotManagerService>();
|
||||
robotController = robotManagerService.GetRobotController(robotId);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Error($"SendOrderToRobotAsync: Error getting robot controller for {robotId}: {ex.Message}");
|
||||
return false;
|
||||
}
|
||||
|
||||
if (robotController == null)
|
||||
{
|
||||
_logger.Warning($"SendOrderToRobotAsync: Robot controller not found for robot {robotId}");
|
||||
return false;
|
||||
}
|
||||
|
||||
// Build nodes and edges
|
||||
var nodes = new List<Node>();
|
||||
var edges = new List<Edge>();
|
||||
|
||||
if (isInitial)
|
||||
{
|
||||
// Initial order: send ALL Base + Horizon segments
|
||||
// Add Base segments (released = true)
|
||||
foreach (var segment in route.Base)
|
||||
{
|
||||
if (segment.VdaNode != null)
|
||||
{
|
||||
var node = CloneNode(segment.VdaNode);
|
||||
node.Released = true; // Base segments are always released
|
||||
nodes.Add(node);
|
||||
}
|
||||
|
||||
if (segment.VdaEdge != null)
|
||||
{
|
||||
var edge = CloneEdge(segment.VdaEdge);
|
||||
edge.Released = true; // Base segments are always released
|
||||
edges.Add(edge);
|
||||
}
|
||||
}
|
||||
|
||||
// Add Horizon segments (released = false)
|
||||
foreach (var segment in route.Horizon)
|
||||
{
|
||||
if (segment.VdaNode != null)
|
||||
{
|
||||
var node = CloneNode(segment.VdaNode);
|
||||
node.Released = false; // Horizon segments are not released
|
||||
nodes.Add(node);
|
||||
}
|
||||
|
||||
if (segment.VdaEdge != null)
|
||||
{
|
||||
var edge = CloneEdge(segment.VdaEdge);
|
||||
edge.Released = false; // Horizon segments are not released
|
||||
edges.Add(edge);
|
||||
}
|
||||
}
|
||||
|
||||
// Create initial Order
|
||||
var order = new OrderMsg
|
||||
{
|
||||
OrderId = route.OrderId,
|
||||
OrderUpdateId = 0,
|
||||
SerialNumber = robotId,
|
||||
Timestamp = DateTime.UtcNow,
|
||||
Nodes = [.. nodes],
|
||||
Edges = [.. edges]
|
||||
};
|
||||
|
||||
var result = await robotController.SendOrderAsync(order);
|
||||
if (result.IsSuccess)
|
||||
{
|
||||
route.OrderUpdateId = order.OrderUpdateId;
|
||||
_logger.Info($"SendOrderToRobotAsync: Successfully sent initial Order (ID: {route.OrderId}, UpdateID: {order.OrderUpdateId}) to robot {robotId}");
|
||||
return true;
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.Warning($"SendOrderToRobotAsync: Failed to send initial order to robot {robotId}: {result.Message}");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
// OrderUpdate: Send stitching node + new Base segments + new Horizon segments
|
||||
// 1. Stitching node: last node of old Base (before release)
|
||||
// 2. New Base: segments that were just released from Horizon
|
||||
// 3. New Horizon: remaining segments in Horizon
|
||||
|
||||
if (baseCountBeforeRelease < 0)
|
||||
{
|
||||
// Fallback: use current Base.Count - 1 (assume only 1 segment was released)
|
||||
// This shouldn't happen if called from TryReleaseNextHorizonSegmentAsync
|
||||
baseCountBeforeRelease = Math.Max(0, route.Base.Count - 1);
|
||||
}
|
||||
|
||||
// Get stitching node: last node of Base before release
|
||||
var oldBaseSegments = route.Base.Take(baseCountBeforeRelease).ToList();
|
||||
var lastOldBaseNode = oldBaseSegments.LastOrDefault(s => s.VdaNode != null);
|
||||
if (lastOldBaseNode?.VdaNode == null)
|
||||
{
|
||||
_logger.Warning($"SendOrderToRobotAsync: Cannot create OrderUpdate for robot {robotId}: no old base node found");
|
||||
return false;
|
||||
}
|
||||
|
||||
// 1. Add stitching node (last node of old Base, released = true)
|
||||
var stitchingNode = CloneNode(lastOldBaseNode.VdaNode);
|
||||
stitchingNode.Released = true;
|
||||
nodes.Add(stitchingNode);
|
||||
|
||||
// 2. Add new Base segments (segments that were just released, released = true)
|
||||
var newBaseSegments = route.Base.Skip(baseCountBeforeRelease).ToList();
|
||||
foreach (var segment in newBaseSegments)
|
||||
{
|
||||
if (segment.VdaNode != null)
|
||||
{
|
||||
var node = CloneNode(segment.VdaNode);
|
||||
node.Released = true; // New Base segments are released
|
||||
nodes.Add(node);
|
||||
}
|
||||
|
||||
if (segment.VdaEdge != null)
|
||||
{
|
||||
var edge = CloneEdge(segment.VdaEdge);
|
||||
edge.Released = true; // New Base segments are released
|
||||
edges.Add(edge);
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Add new Horizon segments (remaining segments in Horizon, released = false)
|
||||
foreach (var segment in route.Horizon)
|
||||
{
|
||||
if (segment.VdaNode != null)
|
||||
{
|
||||
var node = CloneNode(segment.VdaNode);
|
||||
node.Released = false; // Horizon segments are not released
|
||||
nodes.Add(node);
|
||||
}
|
||||
|
||||
if (segment.VdaEdge != null)
|
||||
{
|
||||
var edge = CloneEdge(segment.VdaEdge);
|
||||
edge.Released = false; // Horizon segments are not released
|
||||
edges.Add(edge);
|
||||
}
|
||||
}
|
||||
|
||||
// Get current order to increment OrderUpdateId
|
||||
var currentOrder = robotController.Data.Order;
|
||||
var orderUpdateId = currentOrder?.OrderUpdateId ?? 0;
|
||||
|
||||
var orderUpdate = new OrderMsg
|
||||
{
|
||||
OrderId = route.OrderId,
|
||||
OrderUpdateId = orderUpdateId + 1,
|
||||
SerialNumber = robotId,
|
||||
Timestamp = DateTime.UtcNow,
|
||||
Nodes = [.. nodes],
|
||||
Edges = [.. edges]
|
||||
};
|
||||
|
||||
var result = await robotController.SendOrderAsync(orderUpdate);
|
||||
if (result.IsSuccess)
|
||||
{
|
||||
route.OrderUpdateId = orderUpdate.OrderUpdateId;
|
||||
_logger.Info($"SendOrderToRobotAsync: Successfully sent OrderUpdate (ID: {route.OrderId}, UpdateID: {orderUpdate.OrderUpdateId}) to robot {robotId}");
|
||||
return true;
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.Warning($"SendOrderToRobotAsync: Failed to send OrderUpdate to robot {robotId}: {result.Message}");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Error($"SendOrderToRobotAsync: Error sending order to robot {robotId}: {ex.Message}");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static Node CloneNode(Node source)
|
||||
{
|
||||
return new Node
|
||||
{
|
||||
NodeId = source.NodeId,
|
||||
SequenceId = source.SequenceId,
|
||||
Released = source.Released,
|
||||
NodeDescription = source.NodeDescription,
|
||||
NodePosition = source.NodePosition is null ? null : new NodePosition
|
||||
{
|
||||
X = source.NodePosition.X,
|
||||
Y = source.NodePosition.Y,
|
||||
Theta = source.NodePosition.Theta,
|
||||
AllowedDeviationXY = source.NodePosition.AllowedDeviationXY,
|
||||
AllowedDeviationTheta = source.NodePosition.AllowedDeviationTheta,
|
||||
MapId = source.NodePosition.MapId,
|
||||
MapDescription = source.NodePosition.MapDescription
|
||||
},
|
||||
Actions = source.Actions?.Select(a => CloneAction(a)).ToArray() ?? [] // Clone each action
|
||||
};
|
||||
}
|
||||
|
||||
private static RobotNet.VDA5050.InstantAction.Action CloneAction(RobotNet.VDA5050.InstantAction.Action source)
|
||||
{
|
||||
return new RobotNet.VDA5050.InstantAction.Action
|
||||
{
|
||||
ActionType = source.ActionType,
|
||||
ActionId = source.ActionId,
|
||||
ActionDescription = source.ActionDescription,
|
||||
BlockingType = source.BlockingType,
|
||||
ActionParameters = source.ActionParameters?.Select(p => new RobotNet.VDA5050.InstantAction.ActionParameter
|
||||
{
|
||||
Key = p.Key,
|
||||
Value = p.Value
|
||||
}).ToArray() ?? null
|
||||
};
|
||||
}
|
||||
|
||||
private static Edge CloneEdge(Edge source)
|
||||
{
|
||||
return new Edge
|
||||
{
|
||||
EdgeId = source.EdgeId,
|
||||
SequenceId = source.SequenceId,
|
||||
Released = source.Released,
|
||||
EdgeDescription = source.EdgeDescription,
|
||||
StartNodeId = source.StartNodeId,
|
||||
EndNodeId = source.EndNodeId,
|
||||
MaxSpeed = source.MaxSpeed,
|
||||
MaxHeight = source.MaxHeight,
|
||||
MinHeight = source.MinHeight,
|
||||
Orientation = source.Orientation,
|
||||
OrientationType = source.OrientationType,
|
||||
Direction = source.Direction,
|
||||
RotationAllowed = source.RotationAllowed,
|
||||
MaxRotationSpeed = source.MaxRotationSpeed,
|
||||
Length = source.Length,
|
||||
Trajectory = source.Trajectory == null ? null : new Trajectory
|
||||
{
|
||||
Degree = source.Trajectory.Degree,
|
||||
KnotVector = [.. source.Trajectory.KnotVector], // Clone array
|
||||
ControlPoints = [.. source.Trajectory.ControlPoints.Select(cp => new ControlPoint
|
||||
{
|
||||
X = cp.X,
|
||||
Y = cp.Y,
|
||||
Weight = cp.Weight
|
||||
})]
|
||||
},
|
||||
Corridor = source.Corridor == null ? null : new Corridor
|
||||
{
|
||||
LeftWidth = source.Corridor.LeftWidth,
|
||||
RightWidth = source.Corridor.RightWidth,
|
||||
CorridorRefPoint = source.Corridor.CorridorRefPoint
|
||||
},
|
||||
Actions = source.Actions?.Select(a => CloneAction(a)).ToArray() ?? [] // Clone each action
|
||||
};
|
||||
}
|
||||
|
||||
private void CheckOrderCompletion(string robotId, OrderACSState orderState, StateMsg stateMsg)
|
||||
{
|
||||
try
|
||||
{
|
||||
// Check if NodeStates and EdgeStates are empty
|
||||
var nodeStatesEmpty = stateMsg.NodeStates == null || stateMsg.NodeStates.Length == 0;
|
||||
var edgeStatesEmpty = stateMsg.EdgeStates == null || stateMsg.EdgeStates.Length == 0;
|
||||
|
||||
if (!nodeStatesEmpty || !edgeStatesEmpty)
|
||||
{
|
||||
// Still processing, not completed yet
|
||||
return;
|
||||
}
|
||||
|
||||
// NodeStates and EdgeStates are empty - check completion conditions
|
||||
var lastNodeInRoute = orderState.Route.FullRoute.LastOrDefault()?.VdaNode?.NodeId;
|
||||
var lastNodeId = stateMsg.LastNodeId;
|
||||
|
||||
// Check if LastNodeId matches the last node in route
|
||||
bool isAtLastNode = lastNodeId == lastNodeInRoute;
|
||||
|
||||
// Check actions on last node - get last node's actions from route
|
||||
bool allActionsFinished = true;
|
||||
bool hasActionFailed = false;
|
||||
|
||||
if (stateMsg.ActionStates != null && stateMsg.ActionStates.Length > 0)
|
||||
{
|
||||
// Get actions from last node in route
|
||||
var lastNodeSegment = orderState.Route.FullRoute.LastOrDefault(s => s.VdaNode != null);
|
||||
if (lastNodeSegment?.VdaNode?.Actions != null && lastNodeSegment.VdaNode.Actions.Length > 0)
|
||||
{
|
||||
// Check if all actions from last node are finished
|
||||
var lastNodeActionIds = lastNodeSegment.VdaNode.Actions.Select(a => a.ActionId).ToHashSet();
|
||||
var lastNodeActionStates = stateMsg.ActionStates
|
||||
.Where(a => lastNodeActionIds.Contains(a.ActionId))
|
||||
.ToList();
|
||||
|
||||
if (lastNodeActionStates.Count > 0)
|
||||
{
|
||||
foreach (var actionState in lastNodeActionStates)
|
||||
{
|
||||
if (actionState.ActionStatus == RobotNet.VDA5050.Type.ActionStatus.FAILED)
|
||||
{
|
||||
hasActionFailed = true;
|
||||
allActionsFinished = false;
|
||||
break;
|
||||
}
|
||||
else if (actionState.ActionStatus != RobotNet.VDA5050.Type.ActionStatus.FINISHED)
|
||||
{
|
||||
allActionsFinished = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Determine order status
|
||||
if (isAtLastNode && allActionsFinished)
|
||||
{
|
||||
if (orderState.Status != OrderStatus.IsCompleted) _logger.Info($"CheckOrderCompletion: Order completed successfully for robot {robotId}");
|
||||
// Order completed successfully
|
||||
orderState.Status = OrderStatus.IsCompleted;
|
||||
orderState.LastUpdated = DateTime.UtcNow;
|
||||
}
|
||||
else if (!isAtLastNode || hasActionFailed)
|
||||
{
|
||||
// Order error: not at last node or action failed
|
||||
orderState.Status = OrderStatus.IsError;
|
||||
if (!isAtLastNode)
|
||||
{
|
||||
orderState.Error = $"Robot {robotId} finished at node {lastNodeId} but expected last node {lastNodeInRoute}";
|
||||
}
|
||||
else if (hasActionFailed)
|
||||
{
|
||||
orderState.Error = $"Robot {robotId} has failed actions on last node {lastNodeId}";
|
||||
}
|
||||
orderState.LastUpdated = DateTime.UtcNow;
|
||||
_logger.Warning($"CheckOrderCompletion: Order error for robot {robotId}: {orderState.Error}");
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Error($"CheckOrderCompletion: Error for robot {robotId}: {ex.Message}");
|
||||
orderState.Status = OrderStatus.IsError;
|
||||
orderState.Error = ex.Message;
|
||||
}
|
||||
}
|
||||
|
||||
public RobotRoute? GetRobotRoute(string robotId)
|
||||
{
|
||||
if (_orderStates.TryGetValue(robotId, out var state))
|
||||
{
|
||||
return state.Route;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
namespace RobotNet10.FleetManager.Services.TrafficControl.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Severity levels for conflicts
|
||||
/// </summary>
|
||||
public enum ConflictSeverity
|
||||
{
|
||||
/// <summary>
|
||||
/// Low severity - can be resolved by waiting
|
||||
/// </summary>
|
||||
Low,
|
||||
|
||||
/// <summary>
|
||||
/// Medium severity - needs route adjustment
|
||||
/// </summary>
|
||||
Medium,
|
||||
|
||||
/// <summary>
|
||||
/// High severity - needs complete reroute
|
||||
/// </summary>
|
||||
High
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
namespace RobotNet10.FleetManager.Services.TrafficControl.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Types of conflicts between robots
|
||||
/// </summary>
|
||||
public enum ConflictType
|
||||
{
|
||||
/// <summary>
|
||||
/// Xảy ra khi hai robot sử dụng cùng một cạnh (edge) trong biểu đồ đường đi (graph)
|
||||
/// trong khoảng thời gian trùng lặp, đồng thời lộ trình tiếp theo của robot chồng lên nhau.
|
||||
/// </summary>
|
||||
Confrontation,
|
||||
|
||||
/// <summary>
|
||||
/// Xảy ra khi hai robot sử dụng cùng một cạnh (edge) trong biểu đồ đường đi (graph)
|
||||
/// trong khoảng thời gian trùng lặp nhưng lộ trình tiếp theo của 2 robot không chồng lấn lên nhau.
|
||||
/// </summary>
|
||||
Edge,
|
||||
|
||||
/// <summary>
|
||||
/// Xảy ra khi hai robot chiếm cùng một nút (vertex/node) trong biểu đồ đường đi
|
||||
/// tại cùng một thời điểm hoặc trong khoảng thời gian trùng lặp.
|
||||
/// </summary>
|
||||
Vertex,
|
||||
|
||||
/// <summary>
|
||||
/// Xảy ra khi hai robot ở quá gần nhau (dựa trên khoảng cách Euclidean) trong không gian liên tục,
|
||||
/// vi phạm khoảng cách an toàn (minDistance).
|
||||
/// </summary>
|
||||
Proximity,
|
||||
|
||||
/// <summary>
|
||||
/// Xảy ra khi hai robot di chuyển qua một hành lang hẹp (thường được biểu diễn bằng một chuỗi cạnh hoặc node)
|
||||
/// theo hướng ngược nhau, dẫn đến tình trạng không thể vượt qua nhau.
|
||||
/// </summary>
|
||||
Corridor,
|
||||
|
||||
/// <summary>
|
||||
/// Xảy ra khi hai robot có lộ trình giao nhau về mặt thời gian, nhưng không nhất thiết ở cùng một cạnh hoặc nút,
|
||||
/// mà ở các vị trí khiến chúng không thể di chuyển tiếp mà không va chạm.
|
||||
/// </summary>
|
||||
Temporal,
|
||||
|
||||
/// <summary>
|
||||
/// Xảy ra khi hai robot cần xoay tại một điểm (thường là node) và không gian xoay bị chồng lấn,
|
||||
/// dẫn đến va chạm hoặc cản trở.
|
||||
/// </summary>
|
||||
Rotation,
|
||||
|
||||
/// <summary>
|
||||
/// Xảy ra khi các robot cạnh tranh cho một tài nguyên chung (ví dụ: một khu vực làm việc, điểm sạc, hoặc thiết bị nâng)
|
||||
/// </summary>
|
||||
Resource,
|
||||
|
||||
/// <summary>
|
||||
/// Xảy ra lỗi khi kiểm tra xung đột
|
||||
/// </summary>
|
||||
None
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
namespace RobotNet10.FleetManager.Services.TrafficControl.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Reasons for robot priority
|
||||
/// </summary>
|
||||
public enum PriorityReason
|
||||
{
|
||||
/// <summary>
|
||||
/// Emergency situation
|
||||
/// </summary>
|
||||
Emergency,
|
||||
|
||||
/// <summary>
|
||||
/// High value order
|
||||
/// </summary>
|
||||
HighValueOrder,
|
||||
|
||||
/// <summary>
|
||||
/// Time critical requirement
|
||||
/// </summary>
|
||||
TimeCritical,
|
||||
|
||||
/// <summary>
|
||||
/// Manual override by user
|
||||
/// </summary>
|
||||
ManualOverride,
|
||||
|
||||
/// <summary>
|
||||
/// Default priority
|
||||
/// </summary>
|
||||
Default
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
namespace RobotNet10.FleetManager.Services.TrafficControl.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Status of edge reservations
|
||||
/// </summary>
|
||||
public enum ReservationStatus
|
||||
{
|
||||
/// <summary>
|
||||
/// Edge is reserved but not yet in use
|
||||
/// </summary>
|
||||
Reserved,
|
||||
|
||||
/// <summary>
|
||||
/// Robot is currently using the edge
|
||||
/// </summary>
|
||||
InUse,
|
||||
|
||||
/// <summary>
|
||||
/// Reservation has been released
|
||||
/// </summary>
|
||||
Released
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
namespace RobotNet10.FleetManager.Services.TrafficControl.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Specific actions for conflict resolution
|
||||
/// </summary>
|
||||
public enum ResolutionAction
|
||||
{
|
||||
/// <summary>
|
||||
/// Wait at node (add wait node to Horizon)
|
||||
/// </summary>
|
||||
Wait,
|
||||
|
||||
/// <summary>
|
||||
/// Reroute (calculate new route for Horizon)
|
||||
/// </summary>
|
||||
Reroute
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
namespace RobotNet10.FleetManager.Services.TrafficControl.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Strategies for resolving conflicts
|
||||
/// </summary>
|
||||
public enum ResolutionStrategy
|
||||
{
|
||||
/// <summary>
|
||||
/// Robot waits at node (can only add wait node to Horizon, NOT to Base)
|
||||
/// </summary>
|
||||
WaitAtNode,
|
||||
|
||||
/// <summary>
|
||||
/// Robot reroutes (can only reroute Horizon, NOT Base)
|
||||
/// </summary>
|
||||
Reroute
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
using RobotNet.VDA5050;
|
||||
using RobotNet.VDA5050.Order;
|
||||
using RobotNet10.GlobalPathPlanner.Model;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace RobotNet10.FleetManager.Services.TrafficControl.Helpers;
|
||||
|
||||
/// <summary>
|
||||
/// Helper class to convert MapManager entities to GlobalPathPlanner models
|
||||
/// </summary>
|
||||
public static class MapDataConverter
|
||||
{
|
||||
/// <summary>
|
||||
/// Convert Node (MapManager) to GlobalNode (PathPlanner)
|
||||
/// </summary>
|
||||
public static GlobalNode ToGlobalNode(MapManager.Data.Node node, Guid mapId)
|
||||
{
|
||||
return new GlobalNode
|
||||
{
|
||||
Id = node.Id,
|
||||
MapId = mapId,
|
||||
Name = node.NodeName ?? node.NodeId,
|
||||
X = node.X,
|
||||
Y = node.Y,
|
||||
Orientation = Orientation.NONE // Default, can be enhanced later
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Convert Edge (MapManager) to GlobalEdge (PathPlanner)
|
||||
/// </summary>
|
||||
public static GlobalEdge ToGlobalEdge(MapManager.Data.Edge edge, Guid mapId, Guid vehicleId)
|
||||
{
|
||||
var globalEdge = new GlobalEdge
|
||||
{
|
||||
Id = edge.Id,
|
||||
MapId = mapId,
|
||||
StartNodeId = edge.StartNodeId,
|
||||
EndNodeId = edge.EndNodeId,
|
||||
Degree = 1,
|
||||
ControlPoint1X = 0.0,
|
||||
ControlPoint1Y = 0.0,
|
||||
ControlPoint2X = 0.0,
|
||||
ControlPoint2Y = 0.0
|
||||
};
|
||||
// Get edge vehicle properties for trajectory (if available)
|
||||
var vehicleProperty = edge.VehicleProperties?.FirstOrDefault(prop => prop.VehicleTypeId == vehicleId);
|
||||
if(vehicleProperty is not null)
|
||||
{
|
||||
// Use trajectory fields directly from Entity
|
||||
if (vehicleProperty.TrajectoryDegree.HasValue)
|
||||
{
|
||||
globalEdge.Degree = vehicleProperty.TrajectoryDegree.Value;
|
||||
}
|
||||
|
||||
globalEdge.ControlPoint1X = vehicleProperty.TrajectoryControlPoint1X ?? 0.0;
|
||||
globalEdge.ControlPoint1Y = vehicleProperty.TrajectoryControlPoint1Y ?? 0.0;
|
||||
globalEdge.ControlPoint2X = vehicleProperty.TrajectoryControlPoint2X ?? 0.0;
|
||||
globalEdge.ControlPoint2Y = vehicleProperty.TrajectoryControlPoint2Y ?? 0.0;
|
||||
}
|
||||
return globalEdge;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Convert list of Nodes to GlobalNodes
|
||||
/// </summary>
|
||||
public static GlobalNode[] ToGlobalNodes(IEnumerable<MapManager.Data.Node> nodes, Guid mapId)
|
||||
{
|
||||
return [.. nodes.Select(n => ToGlobalNode(n, mapId))];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Convert list of Edges to GlobalEdges
|
||||
/// </summary>
|
||||
public static GlobalEdge[] ToGlobalEdges(IEnumerable<MapManager.Data.Edge> edges, Guid mapId, Guid vehicleId)
|
||||
{
|
||||
return [.. edges.Select(e => ToGlobalEdge(e, mapId, vehicleId))];
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,462 @@
|
||||
using RobotNet.VDA5050;
|
||||
using RobotNet.VDA5050.Order;
|
||||
using RobotNet10.FleetManager.Services.TrafficControl.Models;
|
||||
using RobotNet10.GlobalPathPlanner.Model;
|
||||
using RobotNet10.MapManager.Data;
|
||||
using Edge = RobotNet10.MapManager.Data.Edge;
|
||||
using Node = RobotNet10.MapManager.Data.Node;
|
||||
|
||||
namespace RobotNet10.FleetManager.Services.TrafficControl.Helpers;
|
||||
|
||||
/// <summary>
|
||||
/// Helper class to convert path planner results to RobotRoute
|
||||
/// </summary>
|
||||
public static class RouteConverter
|
||||
{
|
||||
/// <summary>
|
||||
/// Convert path planner result (GlobalNode[], GlobalEdge[]) to RobotRoute
|
||||
/// </summary>
|
||||
public static RobotRoute ConvertToRobotRoute(
|
||||
string robotId,
|
||||
GlobalNode[] pathNodes,
|
||||
GlobalEdge[] pathEdges,
|
||||
List<Node> allNodes,
|
||||
List<Edge> allEdges,
|
||||
double? lastAngle,
|
||||
object? logger = null, // Accept any logger type for flexibility
|
||||
Guid? vehicleTypeId = null, // VehicleTypeId to get VehicleProperties
|
||||
string? mapId = null) // MapId (LevelId as string) for NodePosition
|
||||
{
|
||||
// Log virtual node creation
|
||||
if (logger != null)
|
||||
{
|
||||
try
|
||||
{
|
||||
var loggerType = logger.GetType();
|
||||
var infoMethod = loggerType.GetMethod("Info", [typeof(string)]);
|
||||
infoMethod?.Invoke(logger, [$"ConvertToRobotRoute: {pathNodes.Length} node, {pathEdges.Length}"]);
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
var route = new RobotRoute
|
||||
{
|
||||
RobotId = robotId,
|
||||
OrderId = Guid.NewGuid().ToString(), // Generate new order ID
|
||||
OrderUpdateId = 0,
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
LastUpdated = DateTime.UtcNow
|
||||
};
|
||||
|
||||
var segments = new List<RouteSegment>();
|
||||
var nodeMap = allNodes.ToDictionary(n => n.Id, n => n);
|
||||
var edgeMap = allEdges.ToDictionary(e => e.Id, e => e);
|
||||
|
||||
// Get LevelId from first available node (for creating virtual nodes/edges)
|
||||
var levelId = allNodes.FirstOrDefault()?.LevelId ?? Guid.Empty;
|
||||
|
||||
// Create segments following VDA5050 pattern:
|
||||
// Node (seq 0), Edge (seq 1), Node (seq 2), Edge (seq 3), ..., Node (seq N)
|
||||
// Route: n nodes, n-1 edges
|
||||
|
||||
int sequenceId = 0;
|
||||
|
||||
for (int i = 0; i < pathNodes.Length; i++)
|
||||
{
|
||||
var globalNode = pathNodes[i];
|
||||
|
||||
// Check if node exists in map, if not (first node when robot is on edge), create virtual node
|
||||
if (!nodeMap.TryGetValue(globalNode.Id, out Node? node))
|
||||
{
|
||||
// This is a virtual node created by A* when robot is on an edge
|
||||
// Only the first node can be virtual
|
||||
if (i == 0)
|
||||
{
|
||||
// Get LevelId from next node if available, otherwise use from allNodes
|
||||
if (pathNodes.Length > 1 && nodeMap.TryGetValue(pathNodes[1].Id, out var nextNode))
|
||||
{
|
||||
levelId = nextNode.LevelId;
|
||||
}
|
||||
|
||||
// Create virtual node from GlobalNode
|
||||
node = new Node
|
||||
{
|
||||
Id = globalNode.Id,
|
||||
LevelId = levelId,
|
||||
NodeId = globalNode.Id.ToString(),
|
||||
NodeName = "Virtual Start Node",
|
||||
X = globalNode.X,
|
||||
Y = globalNode.Y
|
||||
};
|
||||
|
||||
// Log virtual node creation
|
||||
if (logger != null)
|
||||
{
|
||||
try
|
||||
{
|
||||
var loggerType = logger.GetType();
|
||||
var infoMethod = loggerType.GetMethod("Info", [typeof(string)]);
|
||||
infoMethod?.Invoke(logger, [$"Created virtual start node {globalNode.Id} at ({globalNode.X:F2}, {globalNode.Y:F2}) - robot is on edge"]);
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
// Node not found and not first node - this is an error
|
||||
if (logger != null)
|
||||
{
|
||||
try
|
||||
{
|
||||
var loggerType = logger.GetType();
|
||||
var errorMethod = loggerType.GetMethod("Error", [typeof(string)]);
|
||||
errorMethod?.Invoke(logger, [$"Node {globalNode.Id} not found in map at index {i}"]);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Ignore if Error method doesn't exist
|
||||
}
|
||||
}
|
||||
throw new InvalidOperationException($"Node {globalNode.Id} not found in map at index {i}");
|
||||
}
|
||||
}
|
||||
|
||||
// Get NodeVehicleProperty for this vehicle type if available
|
||||
NodeVehicleProperty? nodeVehicleProperty = null;
|
||||
if (vehicleTypeId.HasValue && node.VehicleProperties != null)
|
||||
{
|
||||
nodeVehicleProperty = node.VehicleProperties.FirstOrDefault(vp => vp.VehicleTypeId == vehicleTypeId.Value);
|
||||
}
|
||||
|
||||
// Parse actions from NodeVehicleProperty
|
||||
RobotNet.VDA5050.InstantAction.Action[] nodeActions = [];
|
||||
if (!string.IsNullOrEmpty(nodeVehicleProperty?.Actions))
|
||||
{
|
||||
try
|
||||
{
|
||||
var parsedActions = System.Text.Json.JsonSerializer.Deserialize<RobotNet.VDA5050.InstantAction.ActionLIF[]>(nodeVehicleProperty.Actions, JsonOptionExtends.Read);
|
||||
if (parsedActions != null)
|
||||
{
|
||||
nodeActions = [..parsedActions.Where(a => a.RequirementType == RobotNet.VDA5050.Type.RequirementType.REQUIRED).Select(a => new RobotNet.VDA5050.InstantAction.Action()
|
||||
{
|
||||
ActionId = Guid.NewGuid().ToString(),
|
||||
ActionDescription = a.ActionDescription,
|
||||
ActionParameters = [..a.ActionParameters],
|
||||
BlockingType = a.BlockingType,
|
||||
ActionType = a.ActionType
|
||||
})];
|
||||
}
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
|
||||
// Create VDA5050 Node with full information
|
||||
var vdaNode = new RobotNet.VDA5050.Order.Node
|
||||
{
|
||||
NodeId = node.Id.ToString(),
|
||||
SequenceId = sequenceId,
|
||||
Released = false,
|
||||
NodeDescription = node.NodeDescription ?? string.Empty,
|
||||
NodePosition = new NodePosition
|
||||
{
|
||||
X = node.X,
|
||||
Y = node.Y,
|
||||
Theta = i == pathNodes.Length - 1 && lastAngle.HasValue ? lastAngle.Value : nodeVehicleProperty?.Theta,
|
||||
AllowedDeviationXY = nodeVehicleProperty?.AllowedDeviationXY,
|
||||
AllowedDeviationTheta = nodeVehicleProperty?.AllowedDeviationTheta,
|
||||
MapId = node.MapId ?? mapId ?? string.Empty
|
||||
},
|
||||
Actions = nodeActions
|
||||
};
|
||||
|
||||
// Add node segment (even sequence IDs) with VDA5050 Node
|
||||
var nodeSegment = new RouteSegment
|
||||
{
|
||||
NodeId = node.Id,
|
||||
EdgeId = null,
|
||||
StartNodeId = node.Id,
|
||||
EndNodeId = null,
|
||||
Released = false,
|
||||
VdaNode = vdaNode,
|
||||
VdaEdge = null
|
||||
};
|
||||
|
||||
segments.Add(nodeSegment);
|
||||
sequenceId++;
|
||||
|
||||
// Add edge segment if not the last node
|
||||
if (i < pathNodes.Length - 1 && i < pathEdges.Length)
|
||||
{
|
||||
var globalEdge = pathEdges[i];
|
||||
|
||||
// Find edge by Id or by StartNodeId and EndNodeId
|
||||
Edge? edge = allEdges.FirstOrDefault(e => e.StartNodeId == globalEdge.StartNodeId &&
|
||||
e.EndNodeId == globalEdge.EndNodeId);
|
||||
if (edge is null && edgeMap.TryGetValue(globalEdge.Id, out Edge? value))
|
||||
{
|
||||
edge = value;
|
||||
if(i == 0)
|
||||
{
|
||||
edge.StartNodeId = globalEdge.StartNodeId;
|
||||
edge.EndNodeId = globalEdge.EndNodeId;
|
||||
edge.VehicleProperties = [];
|
||||
}
|
||||
}
|
||||
|
||||
// If edge not found and this is the first edge (i == 0), create virtual edge
|
||||
if (edge == null && i == 0)
|
||||
{
|
||||
// Create virtual edge from GlobalEdge
|
||||
edge = new Edge
|
||||
{
|
||||
Id = globalEdge.Id,
|
||||
LevelId = levelId,
|
||||
EdgeId = globalEdge.Id.ToString(), // Virtual edge identifier
|
||||
StartNodeId = globalEdge.StartNodeId, // Current node (may be virtual)
|
||||
EndNodeId = globalEdge.EndNodeId,
|
||||
EdgeDescription = "Virtual Start Edge",
|
||||
};
|
||||
|
||||
// Log virtual edge creation
|
||||
if (logger != null)
|
||||
{
|
||||
try
|
||||
{
|
||||
var loggerType = logger.GetType();
|
||||
var infoMethod = loggerType.GetMethod("Info", [typeof(string)]);
|
||||
infoMethod?.Invoke(logger, [$"Created virtual start edge from node {globalEdge.StartNodeId} to node {globalEdge.EndNodeId} - robot is on edge"]);
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
}
|
||||
|
||||
if (edge != null)
|
||||
{
|
||||
// Get EdgeVehicleProperty for this vehicle type if available
|
||||
EdgeVehicleProperty? edgeVehicleProperty = null;
|
||||
if (vehicleTypeId.HasValue && edge.VehicleProperties != null)
|
||||
{
|
||||
edgeVehicleProperty = edge.VehicleProperties.FirstOrDefault(vp => vp.VehicleTypeId == vehicleTypeId.Value);
|
||||
}
|
||||
|
||||
// Calculate edge length (Euclidean distance between start and end nodes)
|
||||
var dx = pathNodes[i].X - pathNodes[i + 1].X;
|
||||
var dy = pathNodes[i].Y - pathNodes[i + 1].Y;
|
||||
double edgeLength = Math.Sqrt(dx * dx + dy * dy);
|
||||
|
||||
// Build trajectory from EdgeVehicleProperty fields
|
||||
var startNode = pathNodes[i];
|
||||
var endNode = pathNodes[i + 1];
|
||||
Trajectory? trajectory = null;
|
||||
if (edgeVehicleProperty?.TrajectoryDegree.HasValue == true)
|
||||
{
|
||||
var degree = edgeVehicleProperty.TrajectoryDegree.Value;
|
||||
|
||||
// Build control points array based on degree
|
||||
List<ControlPoint> controlPoints =
|
||||
[
|
||||
// Always add start node as first control point
|
||||
new() {
|
||||
X = startNode.X,
|
||||
Y = startNode.Y,
|
||||
Weight = 1.0
|
||||
}
|
||||
];
|
||||
|
||||
// Add control point 1 for degree 2 and 3
|
||||
if (degree >= 2 && edgeVehicleProperty.TrajectoryControlPoint1X.HasValue && edgeVehicleProperty.TrajectoryControlPoint1Y.HasValue)
|
||||
{
|
||||
controlPoints.Add(new ControlPoint
|
||||
{
|
||||
X = edgeVehicleProperty.TrajectoryControlPoint1X.Value,
|
||||
Y = edgeVehicleProperty.TrajectoryControlPoint1Y.Value,
|
||||
Weight = 1.0
|
||||
});
|
||||
}
|
||||
else if (degree >= 2)
|
||||
{
|
||||
// Default: midpoint between start and end
|
||||
controlPoints.Add(new ControlPoint
|
||||
{
|
||||
X = (startNode.X + endNode.X) / 2.0,
|
||||
Y = (startNode.Y + endNode.Y) / 2.0,
|
||||
Weight = 1.0
|
||||
});
|
||||
}
|
||||
|
||||
// Add control point 2 for degree 3
|
||||
if (degree >= 3 && edgeVehicleProperty.TrajectoryControlPoint2X.HasValue && edgeVehicleProperty.TrajectoryControlPoint2Y.HasValue)
|
||||
{
|
||||
controlPoints.Add(new ControlPoint
|
||||
{
|
||||
X = edgeVehicleProperty.TrajectoryControlPoint2X.Value,
|
||||
Y = edgeVehicleProperty.TrajectoryControlPoint2Y.Value,
|
||||
Weight = 1.0
|
||||
});
|
||||
}
|
||||
else if (degree >= 3)
|
||||
{
|
||||
// Default: one-third point from start
|
||||
controlPoints.Add(new ControlPoint
|
||||
{
|
||||
X = startNode.X + (endNode.X - startNode.X) / 3.0,
|
||||
Y = startNode.Y + (endNode.Y - startNode.Y) / 3.0,
|
||||
Weight = 1.0
|
||||
});
|
||||
}
|
||||
|
||||
// Always add end node as last control point
|
||||
controlPoints.Add(new ControlPoint
|
||||
{
|
||||
X = endNode.X,
|
||||
Y = endNode.Y,
|
||||
Weight = 1.0
|
||||
});
|
||||
|
||||
// Build knot vector based on degree
|
||||
double[] knotVector = degree switch
|
||||
{
|
||||
1 => [0, 0, 1, 1],
|
||||
2 => [0, 0, 0, 1, 1, 1],
|
||||
3 => [0, 0, 0, 0, 1, 1, 1, 1],
|
||||
_ => [0, 0, 1, 1] // Default to degree 1
|
||||
};
|
||||
|
||||
trajectory = new Trajectory
|
||||
{
|
||||
Degree = degree,
|
||||
KnotVector = knotVector,
|
||||
ControlPoints = [.. controlPoints]
|
||||
};
|
||||
}
|
||||
|
||||
// Build corridor from EdgeVehicleProperty fields
|
||||
Corridor? corridor = null;
|
||||
if (edgeVehicleProperty != null &&
|
||||
(edgeVehicleProperty.CorridorLeftWidth.HasValue ||
|
||||
edgeVehicleProperty.CorridorRightWidth.HasValue ||
|
||||
edgeVehicleProperty.CorridorRefPoint.HasValue))
|
||||
{
|
||||
corridor = new Corridor
|
||||
{
|
||||
LeftWidth = edgeVehicleProperty.CorridorLeftWidth ?? 0.0,
|
||||
RightWidth = edgeVehicleProperty.CorridorRightWidth ?? 0.0,
|
||||
CorridorRefPoint = edgeVehicleProperty.CorridorRefPoint ?? RobotNet.VDA5050.Type.CorridorRefPoint.KINEMATICCENTER
|
||||
};
|
||||
}
|
||||
|
||||
// Parse actions from EdgeVehicleProperty
|
||||
RobotNet.VDA5050.InstantAction.Action[] edgeActions = [];
|
||||
if (!string.IsNullOrEmpty(edgeVehicleProperty?.Actions))
|
||||
{
|
||||
try
|
||||
{
|
||||
var parsedActions = System.Text.Json.JsonSerializer.Deserialize<RobotNet.VDA5050.InstantAction.ActionLIF[]?>(edgeVehicleProperty.Actions, JsonOptionExtends.Read);
|
||||
if (parsedActions != null)
|
||||
{
|
||||
edgeActions = [..parsedActions.Where(a => a.RequirementType == RobotNet.VDA5050.Type.RequirementType.REQUIRED).Select(a => new RobotNet.VDA5050.InstantAction.Action()
|
||||
{
|
||||
ActionId = Guid.NewGuid().ToString(),
|
||||
ActionDescription = a.ActionDescription,
|
||||
ActionParameters = [..a.ActionParameters],
|
||||
BlockingType = a.BlockingType,
|
||||
ActionType = a.ActionType
|
||||
})];
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Ignore parse errors, use empty array
|
||||
}
|
||||
}
|
||||
|
||||
// tính toán orientation cho robot
|
||||
|
||||
// Create VDA5050 Edge with full information
|
||||
var vdaEdge = new RobotNet.VDA5050.Order.Edge
|
||||
{
|
||||
EdgeId = edge.Id.ToString(),
|
||||
SequenceId = sequenceId,
|
||||
Released = false,
|
||||
EdgeDescription = edge.EdgeDescription,
|
||||
StartNodeId = edge.StartNodeId.ToString(),
|
||||
EndNodeId = edge.EndNodeId.ToString(),
|
||||
MaxSpeed = edgeVehicleProperty?.MaxSpeed,
|
||||
MaxHeight = edgeVehicleProperty?.MaxHeight,
|
||||
MinHeight = edgeVehicleProperty?.MinHeight ,
|
||||
Orientation = startNode.Orientation == Orientation.FORWARD ? 0 : startNode.Orientation == Orientation.BACKWARD ? Math.PI : null,
|
||||
OrientationType = RobotNet.VDA5050.Type.OrientationType.TANGENTIAL,
|
||||
Direction = string.Empty, // Not in EdgeVehicleProperty
|
||||
RotationAllowed = edgeVehicleProperty?.RotationAllowed ,
|
||||
MaxRotationSpeed = edgeVehicleProperty?.MaxRotationSpeed,
|
||||
Length = edgeLength,
|
||||
Trajectory = trajectory,
|
||||
Corridor = corridor,
|
||||
Actions = edgeActions
|
||||
};
|
||||
|
||||
// Add edge segment (odd sequence IDs) with VDA5050 Edge
|
||||
var edgeSegment = new RouteSegment
|
||||
{
|
||||
NodeId = edge.EndNodeId, // Target node of this edge
|
||||
EdgeId = edge.Id,
|
||||
StartNodeId = edge.StartNodeId,
|
||||
EndNodeId = edge.EndNodeId,
|
||||
Released = false,
|
||||
VdaNode = null, // Edge segment doesn't have node
|
||||
VdaEdge = vdaEdge
|
||||
};
|
||||
|
||||
segments.Add(edgeSegment);
|
||||
sequenceId++;
|
||||
}
|
||||
else
|
||||
{
|
||||
// Edge not found and not first edge - this is an error
|
||||
if (logger != null)
|
||||
{
|
||||
try
|
||||
{
|
||||
var loggerType = logger.GetType();
|
||||
var warningMethod = loggerType.GetMethod("Warning", [typeof(string)]);
|
||||
warningMethod?.Invoke(logger, [$"Edge not found for path segment {i}: StartNodeId={globalEdge.StartNodeId}, EndNodeId={globalEdge.EndNodeId}"]);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Ignore if Warning method doesn't exist
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
route.FullRoute = segments;
|
||||
route.CurrentSegmentIndex = 0;
|
||||
|
||||
return route;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Split route into Base and Horizon
|
||||
/// </summary>
|
||||
public static void SplitRouteIntoBaseAndHorizon(RobotRoute route, int baseSegmentCount)
|
||||
{
|
||||
if (route.FullRoute.Count == 0)
|
||||
return;
|
||||
|
||||
// Ensure baseSegmentCount doesn't exceed available segments
|
||||
var actualBaseCount = Math.Min(baseSegmentCount, route.FullRoute.Count - 1);
|
||||
if (actualBaseCount < 1)
|
||||
actualBaseCount = 1; // At least 1 segment in base
|
||||
|
||||
// Split: Base gets first N segments, Horizon gets the rest
|
||||
route.Base = [.. route.FullRoute.Take(actualBaseCount)];
|
||||
route.Horizon = [.. route.FullRoute.Skip(actualBaseCount)];
|
||||
|
||||
// Mark base segments as released
|
||||
foreach (var segment in route.Base)
|
||||
{
|
||||
segment.Released = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
using RobotNet10.FleetManager.Services.TrafficControl.Models;
|
||||
|
||||
namespace RobotNet10.FleetManager.Services.TrafficControl;
|
||||
|
||||
public enum OrderStatus
|
||||
{
|
||||
IsError,
|
||||
IsCompleted,
|
||||
IsProccessing,
|
||||
IsCanceled,
|
||||
Empty
|
||||
}
|
||||
|
||||
|
||||
public interface IOrderControlService
|
||||
{
|
||||
OrderStatus GetRobotOrderStatus(string robotId);
|
||||
RobotRoute? GetRobotRoute(string robotId);
|
||||
Task<bool> CreateRobotOrderAsync(string robotId, RobotRoute route);
|
||||
}
|
||||
@@ -0,0 +1,239 @@
|
||||
using RobotNet10.FleetManager.Services.TrafficControl.Models;
|
||||
|
||||
namespace RobotNet10.FleetManager.Services.TrafficControl;
|
||||
|
||||
/// <summary>
|
||||
/// Service for traffic control and conflict management between robots
|
||||
/// </summary>
|
||||
public interface ITrafficControlService
|
||||
{
|
||||
/// <summary>
|
||||
/// Plans a route from start node to goal node for a robot
|
||||
/// </summary>
|
||||
/// <param name="robotId">Robot ID</param>
|
||||
/// <param name="startNodeId">Start node ID</param>
|
||||
/// <param name="goalNodeId">Goal node ID</param>
|
||||
/// <param name="cancellationToken">Cancellation token</param>
|
||||
/// <returns>RobotRoute if successful, null otherwise</returns>
|
||||
Task<RobotRoute?> PlanRouteAsync(
|
||||
string robotId,
|
||||
Guid startNodeId,
|
||||
Guid goalNodeId,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Plans a route with optional constraints (angle, startDirection, finalDirection)
|
||||
/// </summary>
|
||||
/// <param name="robotId">Robot ID</param>
|
||||
/// <param name="startNodeId">Start node ID</param>
|
||||
/// <param name="goalNodeId">Goal node ID</param>
|
||||
/// <param name="goalAngle">Optional goal angle in degrees</param>
|
||||
/// <param name="startDirection">Optional start direction constraint</param>
|
||||
/// <param name="finalDirection">Optional final direction constraint</param>
|
||||
/// <param name="cancellationToken">Cancellation token</param>
|
||||
/// <returns>RobotRoute if successful, null otherwise</returns>
|
||||
Task<RobotRoute?> PlanRouteAsync(
|
||||
string robotId,
|
||||
Guid startNodeId,
|
||||
Guid goalNodeId,
|
||||
double? goalAngle,
|
||||
RobotNet10.GlobalPathPlanner.Model.Orientation? startDirection,
|
||||
RobotNet10.GlobalPathPlanner.Model.Orientation? finalDirection,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Plans a route from current position (x, y, theta) to goal node
|
||||
/// </summary>
|
||||
/// <param name="robotId">Robot ID</param>
|
||||
/// <param name="x">Current X position</param>
|
||||
/// <param name="y">Current Y position</param>
|
||||
/// <param name="theta">Current orientation in degrees</param>
|
||||
/// <param name="goalNodeId">Goal node ID</param>
|
||||
/// <param name="goalAngle">Optional goal angle in degrees</param>
|
||||
/// <param name="startDirection">Optional start direction constraint</param>
|
||||
/// <param name="finalDirection">Optional final direction constraint</param>
|
||||
/// <param name="cancellationToken">Cancellation token</param>
|
||||
/// <returns>RobotRoute if successful, null otherwise</returns>
|
||||
Task<RobotRoute?> PlanRouteFromPositionAsync(
|
||||
string robotId,
|
||||
double x,
|
||||
double y,
|
||||
double theta,
|
||||
Guid goalNodeId,
|
||||
double? goalAngle,
|
||||
RobotNet10.GlobalPathPlanner.Model.Orientation? startDirection,
|
||||
RobotNet10.GlobalPathPlanner.Model.Orientation? finalDirection,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Plan route from current position (x, y, theta) to goal node
|
||||
/// </summary>
|
||||
Task<RobotRoute?> PlanRouteFromPositionACSTrafficAsync(
|
||||
string robotId,
|
||||
double x,
|
||||
double y,
|
||||
double theta,
|
||||
Guid goalNodeId,
|
||||
double? goalAngle,
|
||||
RobotNet10.GlobalPathPlanner.Model.Orientation? startDirection,
|
||||
RobotNet10.GlobalPathPlanner.Model.Orientation? finalDirection,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Detects all conflicts between active robots
|
||||
/// </summary>
|
||||
/// <param name="cancellationToken">Cancellation token</param>
|
||||
/// <returns>List of detected conflicts</returns>
|
||||
Task<List<Conflict>> DetectConflictsAsync(CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Resolves a conflict
|
||||
/// </summary>
|
||||
/// <param name="conflict">Conflict to resolve</param>
|
||||
/// <param name="cancellationToken">Cancellation token</param>
|
||||
/// <returns>True if resolved successfully</returns>
|
||||
Task<bool> ResolveConflictAsync(Conflict conflict, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Releases horizon segments into base when safe
|
||||
/// </summary>
|
||||
/// <param name="robotId">Robot ID</param>
|
||||
/// <param name="segmentCount">Number of segments to release</param>
|
||||
/// <param name="cancellationToken">Cancellation token</param>
|
||||
/// <returns>True if released successfully</returns>
|
||||
Task<bool> ReleaseHorizonSegmentAsync(
|
||||
string robotId,
|
||||
int segmentCount,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Updates robot route (typically for rerouting)
|
||||
/// </summary>
|
||||
/// <param name="robotId">Robot ID</param>
|
||||
/// <param name="newRoute">New route</param>
|
||||
/// <param name="cancellationToken">Cancellation token</param>
|
||||
/// <returns>True if updated successfully</returns>
|
||||
Task<bool> UpdateRobotRouteAsync(
|
||||
string robotId,
|
||||
RobotRoute newRoute,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets all active routes for all robots
|
||||
/// </summary>
|
||||
/// <returns>Dictionary of robot ID to RobotRoute</returns>
|
||||
Task<Dictionary<string, RobotRoute>> GetAllActiveRoutesAsync();
|
||||
|
||||
/// <summary>
|
||||
/// Gets route for a specific robot
|
||||
/// </summary>
|
||||
/// <param name="robotId">Robot ID</param>
|
||||
/// <returns>RobotRoute if exists, null otherwise</returns>
|
||||
Task<RobotRoute?> GetRobotRouteAsync(string robotId);
|
||||
|
||||
/// <summary>
|
||||
/// Sets priority for a robot
|
||||
/// </summary>
|
||||
/// <param name="robotId">Robot ID</param>
|
||||
/// <param name="priority">Priority information</param>
|
||||
/// <returns>True if set successfully</returns>
|
||||
Task<bool> SetRobotPriorityAsync(string robotId, RobotPriority priority);
|
||||
|
||||
/// <summary>
|
||||
/// Gets priority for a robot
|
||||
/// </summary>
|
||||
/// <param name="robotId">Robot ID</param>
|
||||
/// <returns>RobotPriority (default if not set)</returns>
|
||||
Task<RobotPriority> GetRobotPriorityAsync(string robotId);
|
||||
|
||||
/// <summary>
|
||||
/// Removes priority for a robot (resets to default)
|
||||
/// </summary>
|
||||
/// <param name="robotId">Robot ID</param>
|
||||
/// <returns>True if removed successfully</returns>
|
||||
Task<bool> RemoveRobotPriorityAsync(string robotId);
|
||||
|
||||
/// <summary>
|
||||
/// Evaluates conflicts for resolution optimization
|
||||
/// </summary>
|
||||
/// <param name="conflicts">List of conflicts to evaluate</param>
|
||||
/// <param name="cancellationToken">Cancellation token</param>
|
||||
/// <returns>Sorted list of conflicts by priority</returns>
|
||||
Task<List<Conflict>> EvaluateConflictsForResolutionAsync(
|
||||
List<Conflict> conflicts,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Sends OrderUpdate to robot with new segments
|
||||
/// </summary>
|
||||
/// <param name="robotId">Robot ID</param>
|
||||
/// <param name="newSegments">New segments to add to order</param>
|
||||
/// <param name="cancellationToken">Cancellation token</param>
|
||||
/// <returns>True if sent successfully</returns>
|
||||
Task<bool> SendOrderUpdateAsync(
|
||||
string robotId,
|
||||
List<RouteSegment> newSegments,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Reserves edges for a robot's route segments
|
||||
/// </summary>
|
||||
/// <param name="robotId">Robot ID</param>
|
||||
/// <param name="orderId">Order ID</param>
|
||||
/// <param name="segments">Route segments containing edges to reserve</param>
|
||||
/// <param name="cancellationToken">Cancellation token</param>
|
||||
/// <returns>True if reserved successfully</returns>
|
||||
Task<bool> ReserveEdgesAsync(
|
||||
string robotId,
|
||||
string orderId,
|
||||
List<RouteSegment> segments,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets all reservations for a specific edge
|
||||
/// </summary>
|
||||
/// <param name="edgeId">Edge ID</param>
|
||||
/// <param name="cancellationToken">Cancellation token</param>
|
||||
/// <returns>List of edge reservations</returns>
|
||||
Task<List<EdgeReservation>> GetEdgeReservationsAsync(
|
||||
Guid edgeId,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Checks if an edge is available during a time period
|
||||
/// </summary>
|
||||
/// <param name="edgeId">Edge ID</param>
|
||||
/// <param name="fromTime">Start time</param>
|
||||
/// <param name="toTime">End time</param>
|
||||
/// <param name="cancellationToken">Cancellation token</param>
|
||||
/// <returns>True if available, false otherwise</returns>
|
||||
Task<bool> IsEdgeAvailableAsync(
|
||||
Guid edgeId,
|
||||
DateTime fromTime,
|
||||
DateTime toTime,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Releases all reservations for a robot's order
|
||||
/// </summary>
|
||||
/// <param name="robotId">Robot ID</param>
|
||||
/// <param name="orderId">Order ID</param>
|
||||
/// <param name="cancellationToken">Cancellation token</param>
|
||||
/// <returns>True if released successfully</returns>
|
||||
Task<bool> ReleaseReservationsAsync(
|
||||
string robotId,
|
||||
string orderId,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Checks and releases horizon segments for robots near end of Base
|
||||
/// </summary>
|
||||
/// <param name="robotId">Optional: specific robot ID, null for all robots</param>
|
||||
/// <param name="cancellationToken">Cancellation token</param>
|
||||
Task CheckAndReleaseHorizonsAsync(
|
||||
string? robotId = null,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
namespace RobotNet10.FleetManager.Services.TrafficControl.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Represents a conflict between robots
|
||||
/// </summary>
|
||||
public class Conflict
|
||||
{
|
||||
/// <summary>
|
||||
/// Type of conflict
|
||||
/// </summary>
|
||||
public ConflictType Type { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// List of robot IDs involved in this conflict
|
||||
/// </summary>
|
||||
public List<string> InvolvedRobots { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// List of conflicting edge IDs
|
||||
/// </summary>
|
||||
public List<Guid> ConflictingEdges { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// List of conflicting node IDs
|
||||
/// </summary>
|
||||
public List<Guid> ConflictingNodes { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// When the conflict was detected
|
||||
/// </summary>
|
||||
public DateTime DetectedAt { get; set; } = DateTime.UtcNow;
|
||||
|
||||
/// <summary>
|
||||
/// Severity of the conflict
|
||||
/// </summary>
|
||||
public ConflictSeverity Severity { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Resolution strategy for this conflict
|
||||
/// </summary>
|
||||
public ConflictResolution? Resolution { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Additional details about the conflict
|
||||
/// </summary>
|
||||
public Dictionary<string, object> ConflictDetails { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Estimated number of new conflicts that may arise from resolving this conflict
|
||||
/// </summary>
|
||||
public int EstimatedNewConflicts { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// List of other conflict keys that can be resolved by resolving this conflict
|
||||
/// </summary>
|
||||
public List<string> CanResolveConflicts { get; set; } = new();
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
namespace RobotNet10.FleetManager.Services.TrafficControl.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Represents a resolution strategy for a conflict
|
||||
/// </summary>
|
||||
public class ConflictResolution
|
||||
{
|
||||
/// <summary>
|
||||
/// Resolution strategy (WaitAtNode or Reroute)
|
||||
/// </summary>
|
||||
public ResolutionStrategy Strategy { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Robot ID that needs to take action
|
||||
/// </summary>
|
||||
public string ActionRobotId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Specific action to take
|
||||
/// </summary>
|
||||
public ResolutionAction Action { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Wait until this time (if action is Wait)
|
||||
/// </summary>
|
||||
public DateTime? WaitUntil { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// New route (if action is Reroute)
|
||||
/// </summary>
|
||||
public RobotRoute? NewRoute { get; set; }
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
namespace RobotNet10.FleetManager.Services.TrafficControl.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Represents an edge reservation for a robot
|
||||
/// </summary>
|
||||
public class EdgeReservation
|
||||
{
|
||||
/// <summary>
|
||||
/// Edge ID (Guid from database)
|
||||
/// </summary>
|
||||
public Guid EdgeId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Edge ID string (VDMA LIF edgeId)
|
||||
/// </summary>
|
||||
public string EdgeIdString { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Start Node ID of the edge
|
||||
/// </summary>
|
||||
public Guid StartNodeId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// End Node ID of the edge
|
||||
/// </summary>
|
||||
public Guid EndNodeId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Robot ID that reserved this edge
|
||||
/// </summary>
|
||||
public string RobotId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Order ID associated with this reservation
|
||||
/// </summary>
|
||||
public string OrderId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// When the reservation was created
|
||||
/// </summary>
|
||||
public DateTime ReservedAt { get; set; } = DateTime.UtcNow;
|
||||
|
||||
/// <summary>
|
||||
/// When the reservation expires (based on edge length and robot speed)
|
||||
/// </summary>
|
||||
public DateTime ReservedUntil { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Reservation status
|
||||
/// </summary>
|
||||
public ReservationStatus Status { get; set; } = ReservationStatus.Reserved;
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
using RobotNet10.FleetManager.Services.TrafficControl.ACS;
|
||||
|
||||
namespace RobotNet10.FleetManager.Services.TrafficControl.Models;
|
||||
|
||||
/// <summary>
|
||||
/// State information for an order managed by ACS Order Control
|
||||
/// </summary>
|
||||
public class OrderACSState
|
||||
{
|
||||
/// <summary>
|
||||
/// Robot ID
|
||||
/// </summary>
|
||||
public string RobotId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Order status
|
||||
/// </summary>
|
||||
public OrderStatus Status { get; set; } = OrderStatus.IsProccessing;
|
||||
|
||||
/// <summary>
|
||||
/// Full route (Base + Horizon)
|
||||
/// </summary>
|
||||
public RobotRoute Route { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Set of zone IDs that have been successfully requested (RequestIn completed)
|
||||
/// </summary>
|
||||
public HashSet<string> ZoneRequestInCompleted { get; set; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Set of zone IDs that have been successfully requested (RequestOut completed)
|
||||
/// </summary>
|
||||
public HashSet<string> ZoneRequestOutCompleted { get; set; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Set of region IDs needs to be successfully requested. (IN)
|
||||
/// </summary>
|
||||
public HashSet<string> ZoneRequestInCompleting { get; set; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Set of region IDs needs to be successfully requested. (OUT)
|
||||
/// </summary>
|
||||
public HashSet<string> ZoneRequestOutCompleting { get; set; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Error message if status is IsError
|
||||
/// </summary>
|
||||
public string? Error { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// List of nodes that are mapped to ACS zones for RequestIn (in order of appearance in route)
|
||||
/// Each entry contains: (NodeIdString, ZoneId)
|
||||
/// </summary>
|
||||
public List<(string NodeIdString, string ZoneId)> InMappedNodes { get; set; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// List of nodes that are mapped to ACS zones for RequestOut (in order of appearance in route)
|
||||
/// Each entry contains: (NodeIdString, ZoneId)
|
||||
/// </summary>
|
||||
public List<(string NodeIdString, string ZoneId)> OutMappedNodes { get; set; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Index of the current IN mapped node being processed
|
||||
/// </summary>
|
||||
public int CurrentInMappedNodeIndex { get; set; } = 0;
|
||||
|
||||
/// <summary>
|
||||
/// Timestamp when order was created
|
||||
/// </summary>
|
||||
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||
|
||||
/// <summary>
|
||||
/// Last update timestamp
|
||||
/// </summary>
|
||||
public DateTime LastUpdated { get; set; } = DateTime.UtcNow;
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
namespace RobotNet10.FleetManager.Services.TrafficControl.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Path planning method to use for IPathPlanner
|
||||
/// </summary>
|
||||
public enum PathPlanningMethod
|
||||
{
|
||||
/// <summary>
|
||||
/// Basic path planning (PathPlanning) - No constraints
|
||||
/// </summary>
|
||||
Basic = 0,
|
||||
|
||||
/// <summary>
|
||||
/// Path planning with start direction constraint (PathPlanningWithStartDirection)
|
||||
/// </summary>
|
||||
WithStartDirection = 1,
|
||||
|
||||
/// <summary>
|
||||
/// Path planning with final direction constraint (PathPlanningWithFinalDirection)
|
||||
/// </summary>
|
||||
WithFinalDirection = 2,
|
||||
|
||||
/// <summary>
|
||||
/// Path planning with final angle constraint (PathPlanningWithAngle)
|
||||
/// </summary>
|
||||
WithAngle = 3
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
namespace RobotNet10.FleetManager.Services.TrafficControl.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Robot information needed for conflict detection
|
||||
/// </summary>
|
||||
public class RobotInfo
|
||||
{
|
||||
/// <summary>
|
||||
/// Robot ID
|
||||
/// </summary>
|
||||
public string RobotId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Robot length in meters (from RobotModel)
|
||||
/// </summary>
|
||||
public double Length { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Robot width in meters (from RobotModel)
|
||||
/// </summary>
|
||||
public double Width { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Navigation point X offset in meters (from RobotModel)
|
||||
/// </summary>
|
||||
public double NavigationPointX { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Navigation point Y offset in meters (from RobotModel)
|
||||
/// </summary>
|
||||
public double NavigationPointY { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Current X position (from State message)
|
||||
/// </summary>
|
||||
public double CurrentX { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Current Y position (from State message)
|
||||
/// </summary>
|
||||
public double CurrentY { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Current orientation angle in radians (from State message)
|
||||
/// </summary>
|
||||
public double CurrentTheta { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Last node ID the robot passed through (from State message)
|
||||
/// </summary>
|
||||
public string LastNodeId { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
namespace RobotNet10.FleetManager.Services.TrafficControl.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Represents priority information for a robot
|
||||
/// </summary>
|
||||
public class RobotPriority
|
||||
{
|
||||
/// <summary>
|
||||
/// Robot ID
|
||||
/// </summary>
|
||||
public string RobotId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Priority level (higher number = higher priority)
|
||||
/// </summary>
|
||||
public int PriorityLevel { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Reason for the priority
|
||||
/// </summary>
|
||||
public PriorityReason Reason { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Priority valid until this time (null if permanent)
|
||||
/// </summary>
|
||||
public DateTime? ValidUntil { get; set; }
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
namespace RobotNet10.FleetManager.Services.TrafficControl.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Represents a complete route for a robot with Base and Horizon segments
|
||||
/// </summary>
|
||||
public class RobotRoute
|
||||
{
|
||||
/// <summary>
|
||||
/// Robot ID (SerialNumber)
|
||||
/// </summary>
|
||||
public string RobotId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Order ID from VDA5050
|
||||
/// </summary>
|
||||
public string OrderId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Order Update ID from VDA5050
|
||||
/// </summary>
|
||||
public int OrderUpdateId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Full route (Base + Horizon)
|
||||
/// </summary>
|
||||
public List<RouteSegment> FullRoute { get; set; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Base: Released segments that robot is currently executing
|
||||
/// </summary>
|
||||
public List<RouteSegment> Base { get; set; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Horizon: Unreleased segments waiting for conditions
|
||||
/// </summary>
|
||||
public List<RouteSegment> Horizon { get; set; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Current position index in the route
|
||||
/// </summary>
|
||||
public int CurrentSegmentIndex { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Route creation timestamp
|
||||
/// </summary>
|
||||
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||
|
||||
/// <summary>
|
||||
/// Last update timestamp
|
||||
/// </summary>
|
||||
public DateTime LastUpdated { get; set; } = DateTime.UtcNow;
|
||||
|
||||
/// <summary>
|
||||
/// Number of reroute attempts for this route (to prevent infinite rerouting)
|
||||
/// </summary>
|
||||
public int RerouteAttempts { get; set; } = 0;
|
||||
|
||||
/// <summary>
|
||||
/// Node ID where robot should wait (if WaitAtNode resolution is applied)
|
||||
/// </summary>
|
||||
public Guid? WaitNodeId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Time until which robot should wait at WaitNodeId (if WaitAtNode resolution is applied)
|
||||
/// </summary>
|
||||
public DateTime? WaitUntil { get; set; }
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
using RobotNet.VDA5050.Order;
|
||||
|
||||
namespace RobotNet10.FleetManager.Services.TrafficControl.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Represents a segment in a robot route (either a node or an edge)
|
||||
/// Uses VDA5050.Order.Node and VDA5050.Order.Edge to store full information
|
||||
/// </summary>
|
||||
public class RouteSegment
|
||||
{
|
||||
// Internal properties needed for traffic control logic
|
||||
/// <summary>
|
||||
/// Node ID (Guid from database) - for conflict detection and edge reservation
|
||||
/// </summary>
|
||||
public Guid NodeId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Edge ID (Guid from database) - Null if this is a node-only segment
|
||||
/// </summary>
|
||||
public Guid? EdgeId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Start Node ID of the edge (Guid from database) - for conflict detection
|
||||
/// </summary>
|
||||
public Guid? StartNodeId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// End Node ID of the edge (Guid from database) - for conflict detection
|
||||
/// </summary>
|
||||
public Guid? EndNodeId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether this segment has been released into base
|
||||
/// </summary>
|
||||
public bool Released { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Time until which this edge is reserved
|
||||
/// </summary>
|
||||
public DateTime? ReservedUntil { get; set; }
|
||||
|
||||
// VDA5050 Order objects containing full information from MapEditor
|
||||
/// <summary>
|
||||
/// VDA5050 Node with full information (NodeId, NodeDescription, NodePosition, Actions, etc.)
|
||||
/// </summary>
|
||||
public Node? VdaNode { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// VDA5050 Edge with full information (EdgeId, EdgeDescription, MaxSpeed, Trajectory, Actions, etc.)
|
||||
/// Null if this is a node-only segment
|
||||
/// </summary>
|
||||
public Edge? VdaEdge { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Get SequenceId from VdaNode or VdaEdge
|
||||
/// </summary>
|
||||
public int SequenceId => VdaNode?.SequenceId ?? VdaEdge?.SequenceId ?? 0;
|
||||
|
||||
/// <summary>
|
||||
/// Check if this segment is a node segment
|
||||
/// </summary>
|
||||
public bool IsNode => VdaNode != null;
|
||||
|
||||
/// <summary>
|
||||
/// Check if this segment is an edge segment
|
||||
/// </summary>
|
||||
public bool IsEdge => VdaEdge != null;
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,174 @@
|
||||
using RobotNet10.FleetManager.Shared.Enums;
|
||||
|
||||
namespace RobotNet10.FleetManager.Services.TrafficControl.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Configuration for TrafficControl service
|
||||
/// </summary>
|
||||
public class TrafficControlConfig
|
||||
{
|
||||
/// <summary>
|
||||
/// Conflict detection configuration
|
||||
/// </summary>
|
||||
public ConflictDetectionConfig ConflictDetection { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Base/Horizon management configuration
|
||||
/// </summary>
|
||||
public BaseHorizonConfig BaseHorizon { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Conflict resolution configuration
|
||||
/// </summary>
|
||||
public ConflictResolutionConfig ConflictResolution { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Priority configuration
|
||||
/// </summary>
|
||||
public PriorityConfig Priority { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Path planning configuration
|
||||
/// </summary>
|
||||
public PathPlanningConfig PathPlanning { get; set; } = new();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Conflict detection configuration
|
||||
/// </summary>
|
||||
public class ConflictDetectionConfig
|
||||
{
|
||||
/// <summary>
|
||||
/// Detection interval in milliseconds (default: 500ms = 2 Hz)
|
||||
/// </summary>
|
||||
public int IntervalMs { get; set; } = 500;
|
||||
|
||||
/// <summary>
|
||||
/// Vertex conflict threshold in meters (default: 2.0m)
|
||||
/// </summary>
|
||||
public double VertexConflictThreshold { get; set; } = 2.0;
|
||||
|
||||
/// <summary>
|
||||
/// Minimum safe distance for proximity conflict in meters (default: 1.0m)
|
||||
/// </summary>
|
||||
public double ProximityMinDistance { get; set; } = 1.0;
|
||||
|
||||
/// <summary>
|
||||
/// Time conflict threshold in seconds (default: 5.0s)
|
||||
/// </summary>
|
||||
public double TimeConflictThreshold { get; set; } = 5.0;
|
||||
|
||||
/// <summary>
|
||||
/// Rotation space radius in meters (default: 0.5m)
|
||||
/// </summary>
|
||||
public double RotationSpaceRadius { get; set; } = 0.5;
|
||||
|
||||
/// <summary>
|
||||
/// Corridor width threshold in meters (default: 2.0m)
|
||||
/// </summary>
|
||||
public double CorridorWidthThreshold { get; set; } = 2.0;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Base/Horizon management configuration
|
||||
/// </summary>
|
||||
public class BaseHorizonConfig
|
||||
{
|
||||
/// <summary>
|
||||
/// Initial number of base segments (default: 2)
|
||||
/// </summary>
|
||||
public int InitialBaseSegments { get; set; } = 2;
|
||||
|
||||
/// <summary>
|
||||
/// Number of segments to release ahead (default: 2)
|
||||
/// </summary>
|
||||
public int ReleaseAheadSegments { get; set; } = 2;
|
||||
|
||||
/// <summary>
|
||||
/// Minimum number of horizon segments to keep (default: 1)
|
||||
/// </summary>
|
||||
public int MinHorizonSegments { get; set; } = 1;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Conflict resolution configuration
|
||||
/// </summary>
|
||||
public class ConflictResolutionConfig
|
||||
{
|
||||
/// <summary>
|
||||
/// Maximum wait time at node in seconds (default: 5.0s)
|
||||
/// </summary>
|
||||
public double WaitTimeAtNode { get; set; } = 5.0;
|
||||
|
||||
/// <summary>
|
||||
/// Whether to allow reroute on conflict (default: true)
|
||||
/// </summary>
|
||||
public bool RerouteOnConflict { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Maximum reroute attempts for one conflict (default: 3)
|
||||
/// </summary>
|
||||
public int MaxRerouteAttempts { get; set; } = 3;
|
||||
|
||||
/// <summary>
|
||||
/// Enable resolution optimization (default: true)
|
||||
/// </summary>
|
||||
public bool ResolutionOptimization { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Maximum simulation depth for resolution evaluation (default: 2)
|
||||
/// </summary>
|
||||
public int MaxResolutionSimulationDepth { get; set; } = 2;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Priority configuration
|
||||
/// </summary>
|
||||
public class PriorityConfig
|
||||
{
|
||||
/// <summary>
|
||||
/// Default priority level (default: 0)
|
||||
/// </summary>
|
||||
public int DefaultPriority { get; set; } = 0;
|
||||
|
||||
/// <summary>
|
||||
/// Emergency priority level (default: 100)
|
||||
/// </summary>
|
||||
public int EmergencyPriority { get; set; } = 100;
|
||||
|
||||
/// <summary>
|
||||
/// High value order priority level (default: 50)
|
||||
/// </summary>
|
||||
public int HighValueOrderPriority { get; set; } = 50;
|
||||
|
||||
/// <summary>
|
||||
/// Time critical priority level (default: 30)
|
||||
/// </summary>
|
||||
public int TimeCriticalPriority { get; set; } = 30;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Path planning configuration
|
||||
/// </summary>
|
||||
public class PathPlanningConfig
|
||||
{
|
||||
/// <summary>
|
||||
/// Mapping from NavigationType to PathPlanningMethod
|
||||
/// Defines which IPathPlanner method to use for each NavigationType
|
||||
/// </summary>
|
||||
public Dictionary<NavigationType, PathPlanningMethod> NavigationTypeMethodMapping { get; set; } = new()
|
||||
{
|
||||
// Default mappings
|
||||
{ NavigationType.Differential, PathPlanningMethod.Basic },
|
||||
{ NavigationType.Forklift, PathPlanningMethod.Basic },
|
||||
{ NavigationType.OmniDrive, PathPlanningMethod.Basic }
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Default path planning method if NavigationType is not found in mapping (default: Basic)
|
||||
/// </summary>
|
||||
public PathPlanningMethod DefaultMethod { get; set; } = PathPlanningMethod.Basic;
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,570 @@
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using RobotNet.VDA5050.State;
|
||||
using RobotNet10.FleetManager.Services;
|
||||
using RobotNet10.FleetManager.Services.ConfigManager;
|
||||
using RobotNet10.FleetManager.Services.RobotManager;
|
||||
using RobotNet10.FleetManager.Services.TrafficControl.Models;
|
||||
using RobotNet10.MapManager.Services;
|
||||
|
||||
namespace RobotNet10.FleetManager.Services.TrafficControl.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Service for managing Base/Horizon segments
|
||||
/// </summary>
|
||||
public class BaseHorizonManagementService(
|
||||
Logger<BaseHorizonManagementService> logger,
|
||||
IEdgeReservationService edgeReservationService,
|
||||
IOrderUpdateService orderUpdateService,
|
||||
IServiceScopeFactory serviceScopeFactory,
|
||||
IRouteStorageService routeStorageService,
|
||||
IConflictDetectionService conflictDetectionService,
|
||||
ITrafficConfig trafficConfig) : IBaseHorizonManagementService
|
||||
{
|
||||
private readonly Logger<BaseHorizonManagementService> _logger = logger;
|
||||
private readonly IEdgeReservationService _edgeReservationService = edgeReservationService;
|
||||
private readonly IOrderUpdateService _orderUpdateService = orderUpdateService;
|
||||
private readonly IServiceScopeFactory _serviceScopeFactory = serviceScopeFactory;
|
||||
private readonly IRouteStorageService _routeStorageService = routeStorageService;
|
||||
private readonly IConflictDetectionService _conflictDetectionService = conflictDetectionService;
|
||||
private readonly ITrafficConfig _trafficConfig = trafficConfig ?? throw new ArgumentNullException(nameof(trafficConfig));
|
||||
|
||||
public async Task<(bool IsInBase, int CurrentSegmentIndex, RouteSegment? CurrentSegment)> CheckRobotPositionAsync(
|
||||
string robotId,
|
||||
RobotRoute route,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
// Get robot current state
|
||||
// Resolve IRobotManagerService lazily to avoid circular dependency
|
||||
using var scope = _serviceScopeFactory.CreateScope();
|
||||
var robotManager = scope.ServiceProvider.GetRequiredService<IRobotManagerService>();
|
||||
var robotData = robotManager.GetRobotData(robotId);
|
||||
var state = robotData?.State;
|
||||
|
||||
if (state?.NodeStates == null || state.NodeStates.Length == 0)
|
||||
{
|
||||
// Robot is idle or just started, assume at first node
|
||||
return (false, 0, route.FullRoute.FirstOrDefault());
|
||||
}
|
||||
|
||||
// Find current segment based on lastNodeSequenceId from state
|
||||
var lastNodeSequenceId = state.LastNodeSequenceId;
|
||||
var currentSegment = route.FullRoute.FirstOrDefault(s =>
|
||||
(s.VdaNode != null && s.VdaNode.SequenceId == lastNodeSequenceId) ||
|
||||
(s.VdaEdge != null && s.VdaEdge.SequenceId == lastNodeSequenceId));
|
||||
|
||||
if (currentSegment == null)
|
||||
{
|
||||
// Cannot find segment, assume at start
|
||||
return (false, 0, route.FullRoute.FirstOrDefault());
|
||||
}
|
||||
|
||||
var currentIndex = route.FullRoute.IndexOf(currentSegment);
|
||||
var isInBase = currentIndex < route.Base.Count;
|
||||
|
||||
return (isInBase, currentIndex, currentSegment);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Error($"Error checking robot position for robot {robotId}: {ex.Message}");
|
||||
// Default: assume in Horizon (safer for intervention)
|
||||
return (false, 0, route.FullRoute.FirstOrDefault());
|
||||
}
|
||||
}
|
||||
|
||||
public int CountRemainingBaseSegments(RobotRoute route, StateMsg? state)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (state == null)
|
||||
{
|
||||
// No state available, assume all Base segments remaining
|
||||
return route.Base.Count;
|
||||
}
|
||||
|
||||
if (state.NodeStates == null || state.NodeStates.Length == 0)
|
||||
{
|
||||
return route.Base.Count; // Assume all Base segments remaining
|
||||
}
|
||||
|
||||
// Find current segment based on LastNodeSequenceId
|
||||
var lastNodeSequenceId = state.LastNodeSequenceId;
|
||||
var currentSegment = route.Base.FirstOrDefault(s =>
|
||||
(s.VdaNode != null && s.VdaNode.SequenceId == lastNodeSequenceId) ||
|
||||
(s.VdaEdge != null && s.VdaEdge.SequenceId == lastNodeSequenceId));
|
||||
|
||||
if (currentSegment == null)
|
||||
{
|
||||
return route.Base.Count; // Cannot find current segment, assume all remaining
|
||||
}
|
||||
|
||||
var currentIndex = route.Base.IndexOf(currentSegment);
|
||||
var remainingCount = route.Base.Count - currentIndex - 1;
|
||||
|
||||
return Math.Max(0, remainingCount);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Error($"Error counting remaining Base segments: {ex.Message}");
|
||||
return route.Base.Count; // Default: assume all remaining
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<bool> ReleaseHorizonSegmentAsync(
|
||||
string robotId,
|
||||
int segmentCount,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(robotId))
|
||||
{
|
||||
_logger.Warning("Cannot release horizon segments: robotId is null or empty");
|
||||
return false;
|
||||
}
|
||||
|
||||
if (segmentCount <= 0)
|
||||
{
|
||||
_logger.Warning($"Cannot release horizon segments: segmentCount must be > 0, got {segmentCount}");
|
||||
return false;
|
||||
}
|
||||
|
||||
// Get robot route
|
||||
var route = await _routeStorageService.GetRobotRouteAsync(robotId);
|
||||
if (route == null)
|
||||
{
|
||||
_logger.Warning($"Cannot release horizon segments: route not found for robot {robotId}");
|
||||
return false;
|
||||
}
|
||||
|
||||
if (route.Horizon.Count == 0)
|
||||
{
|
||||
_logger.Debug($"No horizon segments to release for robot {robotId}");
|
||||
return false;
|
||||
}
|
||||
|
||||
// Get next segments from Horizon
|
||||
var segmentsToRelease = route.Horizon.Take(segmentCount).ToList();
|
||||
if (segmentsToRelease.Count == 0)
|
||||
{
|
||||
_logger.Debug($"No segments to release for robot {robotId}");
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check conflicts for segments to release
|
||||
var hasConflicts = await _conflictDetectionService.CheckConflictsForSegmentsAsync(robotId, segmentsToRelease, cancellationToken);
|
||||
if (hasConflicts)
|
||||
{
|
||||
_logger.Debug($"Cannot release horizon segments for robot {robotId}: conflicts detected");
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check if robot is waiting at a node (WaitAtNode resolution)
|
||||
// If WaitUntil has passed, allow release
|
||||
if (route.WaitNodeId.HasValue && route.WaitUntil.HasValue)
|
||||
{
|
||||
var waitNodeInBase = route.Base.LastOrDefault(s => s.NodeId == route.WaitNodeId.Value);
|
||||
if (waitNodeInBase != null)
|
||||
{
|
||||
// Robot is waiting at this node
|
||||
if (DateTime.UtcNow < route.WaitUntil.Value)
|
||||
{
|
||||
// Still waiting, check if conflict is resolved
|
||||
var conflicts = await _conflictDetectionService.CheckConflictsForSegmentsAsync(robotId, segmentsToRelease, cancellationToken);
|
||||
if (conflicts)
|
||||
{
|
||||
_logger.Debug($"Robot {robotId} is waiting at node {route.WaitNodeId.Value}, conflict still exists, cannot release");
|
||||
return false;
|
||||
}
|
||||
// Conflict resolved, clear wait info and allow release
|
||||
_logger.Info($"Conflict resolved for robot {robotId} waiting at node {route.WaitNodeId.Value}, allowing release");
|
||||
route.WaitNodeId = null;
|
||||
route.WaitUntil = null;
|
||||
}
|
||||
else
|
||||
{
|
||||
// Wait time expired, clear wait info
|
||||
_logger.Info($"Wait time expired for robot {robotId} at node {route.WaitNodeId.Value}, clearing wait info");
|
||||
route.WaitNodeId = null;
|
||||
route.WaitUntil = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Reserve edges for newly released segments
|
||||
var orderId = route.OrderId;
|
||||
if (string.IsNullOrEmpty(orderId))
|
||||
{
|
||||
orderId = $"ORDER_{robotId}_{DateTime.UtcNow:yyyyMMddHHmmss}";
|
||||
route.OrderId = orderId;
|
||||
}
|
||||
|
||||
var reserveSuccess = await _edgeReservationService.ReserveEdgesAsync(robotId, orderId, segmentsToRelease, cancellationToken);
|
||||
if (!reserveSuccess)
|
||||
{
|
||||
_logger.Warning($"Failed to reserve edges for horizon segments of robot {robotId}");
|
||||
return false;
|
||||
}
|
||||
|
||||
// Move segments from Horizon to Base
|
||||
foreach (var segment in segmentsToRelease)
|
||||
{
|
||||
segment.Released = true;
|
||||
route.Base.Add(segment);
|
||||
}
|
||||
|
||||
route.Horizon.RemoveRange(0, segmentsToRelease.Count);
|
||||
|
||||
// Update FullRoute
|
||||
route.FullRoute = route.Base.Concat(route.Horizon).ToList();
|
||||
route.LastUpdated = DateTime.UtcNow;
|
||||
|
||||
// Update route in storage
|
||||
await _routeStorageService.UpdateRobotRouteAsync(robotId, route);
|
||||
|
||||
// Generate and send OrderUpdate
|
||||
var orderUpdateSent = await _orderUpdateService.GenerateAndSendOrderUpdateAsync(robotId, route, cancellationToken);
|
||||
if (!orderUpdateSent)
|
||||
{
|
||||
_logger.Warning($"Failed to send OrderUpdate for horizon release to robot {robotId}");
|
||||
// Still return true as segments are already moved to Base
|
||||
}
|
||||
|
||||
_logger.Info($"Successfully released {segmentsToRelease.Count} horizon segments to Base for robot {robotId}");
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Error($"Error releasing horizon segments for robot {robotId}: {ex.Message}");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task CheckAndReleaseHorizonsAsync(
|
||||
string? robotId = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
List<string> robotIdsToCheck;
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(robotId))
|
||||
{
|
||||
// Check specific robot
|
||||
robotIdsToCheck = new List<string> { robotId };
|
||||
}
|
||||
else
|
||||
{
|
||||
// Check all robots with active routes
|
||||
var allRoutes = await _routeStorageService.GetAllActiveRoutesAsync();
|
||||
robotIdsToCheck = allRoutes.Keys.ToList();
|
||||
}
|
||||
|
||||
foreach (var id in robotIdsToCheck)
|
||||
{
|
||||
try
|
||||
{
|
||||
// Get robot route
|
||||
var route = await _routeStorageService.GetRobotRouteAsync(id);
|
||||
if (route == null || route.Horizon.Count == 0)
|
||||
{
|
||||
continue; // No route or no horizon to release
|
||||
}
|
||||
|
||||
// Check robot position
|
||||
var robotPosition = await CheckRobotPositionAsync(id, route, cancellationToken);
|
||||
if (!robotPosition.IsInBase)
|
||||
{
|
||||
continue; // Robot not in Base, skip
|
||||
}
|
||||
|
||||
// Count remaining Base segments
|
||||
// Resolve IRobotManagerService lazily to avoid circular dependency
|
||||
using var scopeForRobotManager = _serviceScopeFactory.CreateScope();
|
||||
var robotManager = scopeForRobotManager.ServiceProvider.GetRequiredService<IRobotManagerService>();
|
||||
var robotData = robotManager.GetRobotData(id);
|
||||
var state = robotData?.State;
|
||||
if (state == null)
|
||||
{
|
||||
// Cannot determine remaining segments without state, skip
|
||||
continue;
|
||||
}
|
||||
|
||||
var remainingBaseSegments = CountRemainingBaseSegments(route, state);
|
||||
|
||||
// Only check if robot is near end of Base (1-2 segments remaining)
|
||||
if (remainingBaseSegments > 2)
|
||||
{
|
||||
continue; // Too early to release
|
||||
}
|
||||
|
||||
// Determine how many segments to release
|
||||
var config = _trafficConfig.GetTrafficControlConfig();
|
||||
var segmentsToRelease = config.BaseHorizon.ReleaseAheadSegments;
|
||||
if (segmentsToRelease > route.Horizon.Count)
|
||||
{
|
||||
segmentsToRelease = route.Horizon.Count;
|
||||
}
|
||||
|
||||
if (segmentsToRelease <= 0)
|
||||
{
|
||||
continue; // Nothing to release
|
||||
}
|
||||
|
||||
// Try to release segments
|
||||
var released = await ReleaseHorizonSegmentAsync(id, segmentsToRelease, cancellationToken);
|
||||
if (released)
|
||||
{
|
||||
_logger.Debug($"Released {segmentsToRelease} horizon segments for robot {id}");
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Error($"Error checking horizon release for robot {id}: {ex.Message}");
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Error($"Error in CheckAndReleaseHorizonsAsync: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<int> CalculateSafeBaseSizeAsync(
|
||||
string robotId,
|
||||
RobotRoute route,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
// Get all active routes (excluding current robot)
|
||||
var allRoutes = await _routeStorageService.GetAllActiveRoutesAsync();
|
||||
var activeRoutes = allRoutes.Values
|
||||
.Where(r => r.RobotId != robotId)
|
||||
.ToList();
|
||||
|
||||
// Get config once
|
||||
var config = _trafficConfig.GetTrafficControlConfig();
|
||||
|
||||
if (activeRoutes.Count == 0)
|
||||
{
|
||||
// No other robots - can use default base size
|
||||
return config.BaseHorizon.InitialBaseSegments;
|
||||
}
|
||||
|
||||
// Start with minimum base size
|
||||
int safeBaseSize = config.BaseHorizon.InitialBaseSegments;
|
||||
int maxBaseSize = route.FullRoute.Count - 1; // At least 1 segment in Horizon
|
||||
|
||||
// Check conflicts for increasing base sizes
|
||||
for (int size = config.BaseHorizon.InitialBaseSegments; size <= maxBaseSize; size++)
|
||||
{
|
||||
var testBase = route.FullRoute.Take(size).ToList();
|
||||
var hasConflict = await CheckConflictsForBaseAsync(
|
||||
robotId,
|
||||
testBase,
|
||||
activeRoutes,
|
||||
cancellationToken);
|
||||
|
||||
if (!hasConflict)
|
||||
{
|
||||
// No conflicts - can use this size
|
||||
safeBaseSize = size;
|
||||
}
|
||||
else
|
||||
{
|
||||
// Has conflicts - stop here, use previous safe size
|
||||
_logger.Debug($"Base size {size} has conflicts for robot {robotId}, using safe size {safeBaseSize}");
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
_logger.Info($"Calculated safe base size for robot {robotId}: {safeBaseSize} segments");
|
||||
return safeBaseSize;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Error($"Error calculating safe base size for robot {robotId}: {ex.Message}");
|
||||
// Return minimum base size on error
|
||||
var config = _trafficConfig.GetTrafficControlConfig();
|
||||
return config.BaseHorizon.InitialBaseSegments;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Check if Base segments have conflicts with other active routes
|
||||
/// </summary>
|
||||
private async Task<bool> CheckConflictsForBaseAsync(
|
||||
string robotId,
|
||||
List<RouteSegment> baseSegments,
|
||||
List<RobotRoute> otherActiveRoutes,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (baseSegments == null || baseSegments.Count == 0)
|
||||
{
|
||||
return false; // No segments = no conflict
|
||||
}
|
||||
|
||||
// Get levelId to fetch edge details
|
||||
var levelId = await GetLevelIdForRobotAsync(robotId, cancellationToken);
|
||||
if (levelId == null)
|
||||
{
|
||||
_logger.Warning($"Cannot get levelId for robot {robotId} to check conflicts");
|
||||
return true; // Assume conflict on error
|
||||
}
|
||||
|
||||
// Get edges with nodes for length calculation
|
||||
using var scope = _serviceScopeFactory.CreateAsyncScope();
|
||||
var _nodeService = scope.ServiceProvider.GetRequiredService<INodeService>();
|
||||
var _edgeService = scope.ServiceProvider.GetRequiredService<IEdgeService>();
|
||||
var edges = await _edgeService.GetEdgesByLevelAsync(levelId.Value, includeNodes: true, includeVehicleProperties: true);
|
||||
var nodes = await _nodeService.GetNodesByLevelAsync(levelId.Value, includeVehicleProperties: true);
|
||||
|
||||
// Create lookup dictionaries
|
||||
var edgeDict = edges.ToDictionary(e => e.Id);
|
||||
var nodeDict = nodes.ToDictionary(n => n.Id);
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
|
||||
// Check each edge segment in Base
|
||||
foreach (var segment in baseSegments)
|
||||
{
|
||||
if (segment.EdgeId == null) continue; // Skip node segments
|
||||
|
||||
var edgeId = segment.EdgeId.Value;
|
||||
if (!edgeDict.TryGetValue(edgeId, out var edge))
|
||||
{
|
||||
continue; // Edge not found, skip
|
||||
}
|
||||
|
||||
// Get start and end nodes
|
||||
if (!nodeDict.TryGetValue(edge.StartNodeId, out var startNode) ||
|
||||
!nodeDict.TryGetValue(edge.EndNodeId, out var endNode))
|
||||
{
|
||||
continue; // Nodes not found, skip
|
||||
}
|
||||
|
||||
// Calculate edge length
|
||||
var edgeLength = CalculateEdgeLength(startNode, endNode);
|
||||
|
||||
// Get max speed from EdgeVehicleProperty
|
||||
double? maxSpeed = null;
|
||||
if (edge.VehicleProperties != null && edge.VehicleProperties.Any())
|
||||
{
|
||||
maxSpeed = edge.VehicleProperties.FirstOrDefault()?.MaxSpeed;
|
||||
}
|
||||
|
||||
// Calculate reservation duration for this edge
|
||||
var duration = CalculateReservationDuration(edgeLength, maxSpeed, 1.0);
|
||||
var reservedFrom = now;
|
||||
var reservedUntil = now.Add(duration);
|
||||
|
||||
// Check if edge is available (not reserved by other robots)
|
||||
var isAvailable = await _edgeReservationService.IsEdgeAvailableAsync(edgeId, reservedFrom, reservedUntil, cancellationToken);
|
||||
if (!isAvailable)
|
||||
{
|
||||
_logger.Debug($"Edge {edgeId} is not available for robot {robotId} during {reservedFrom} to {reservedUntil}");
|
||||
return true; // Conflict found
|
||||
}
|
||||
|
||||
// Also check for reverse edge (EdgeBA vs EdgeAB)
|
||||
var reverseEdge = edges.FirstOrDefault(e =>
|
||||
e.StartNodeId == edge.EndNodeId &&
|
||||
e.EndNodeId == edge.StartNodeId);
|
||||
|
||||
if (reverseEdge != null)
|
||||
{
|
||||
var reverseIsAvailable = await _edgeReservationService.IsEdgeAvailableAsync(
|
||||
reverseEdge.Id,
|
||||
reservedFrom,
|
||||
reservedUntil,
|
||||
cancellationToken);
|
||||
if (!reverseIsAvailable)
|
||||
{
|
||||
_logger.Debug($"Reverse edge {reverseEdge.Id} is not available for robot {robotId}");
|
||||
return true; // Conflict found (confrontation)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false; // No conflicts found
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Error($"Error checking conflicts for Base segments of robot {robotId}: {ex.Message}");
|
||||
return true; // Assume conflict on error
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get levelId for a robot from Robot.MapId in database
|
||||
/// </summary>
|
||||
private async Task<Guid?> GetLevelIdForRobotAsync(string robotId, CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
// Use IServiceScopeFactory to create a scope for Scoped services
|
||||
using var scope = _serviceScopeFactory.CreateScope();
|
||||
var robotService = scope.ServiceProvider.GetRequiredService<IRobotService>();
|
||||
|
||||
var robot = await robotService.GetByRobotIdAsync(robotId);
|
||||
if (robot == null)
|
||||
{
|
||||
_logger.Warning($"Robot {robotId} not found in database");
|
||||
return null;
|
||||
}
|
||||
|
||||
if (robot.MapId == null)
|
||||
{
|
||||
_logger.Warning($"Robot {robotId} has no MapId assigned");
|
||||
return null;
|
||||
}
|
||||
|
||||
// Robot.MapId is the levelId (LayoutLevel.Id)
|
||||
return robot.MapId;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Error($"Error getting levelId for robot {robotId}: {ex.Message}");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Calculate edge length from start and end nodes (Euclidean distance)
|
||||
/// </summary>
|
||||
private double CalculateEdgeLength(
|
||||
RobotNet10.MapManager.Data.Node startNode,
|
||||
RobotNet10.MapManager.Data.Node endNode)
|
||||
{
|
||||
var dx = endNode.X - startNode.X;
|
||||
var dy = endNode.Y - startNode.Y;
|
||||
return Math.Sqrt(dx * dx + dy * dy);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Calculate reservation duration based on edge length and robot speed
|
||||
/// </summary>
|
||||
private TimeSpan CalculateReservationDuration(
|
||||
double edgeLength,
|
||||
double? maxSpeed = null,
|
||||
double defaultSpeed = 1.0) // Default speed: 1.0 m/s
|
||||
{
|
||||
var speed = maxSpeed ?? defaultSpeed;
|
||||
if (speed <= 0)
|
||||
{
|
||||
speed = defaultSpeed;
|
||||
}
|
||||
|
||||
// Add buffer time (10% of travel time) for safety
|
||||
var travelTime = edgeLength / speed;
|
||||
var bufferTime = travelTime * 0.1;
|
||||
var totalTime = travelTime + bufferTime;
|
||||
|
||||
return TimeSpan.FromSeconds(Math.Max(totalTime, 0.5)); // Minimum 0.5 seconds
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,331 @@
|
||||
using RobotNet10.FleetManager.Services.TrafficControl.Models;
|
||||
using RobotNet10.MapManager.Services;
|
||||
|
||||
namespace RobotNet10.FleetManager.Services.TrafficControl.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Service for managing edge reservations
|
||||
/// </summary>
|
||||
public class EdgeReservationService(
|
||||
Logger<EdgeReservationService> logger,
|
||||
IServiceScopeFactory serviceScopeFactory,
|
||||
IRobotInfoService robotInfoService) : IEdgeReservationService
|
||||
{
|
||||
private readonly Logger<EdgeReservationService> _logger = logger;
|
||||
private readonly IServiceScopeFactory _serviceScopeFactory = serviceScopeFactory;
|
||||
private readonly IRobotInfoService _robotInfoService = robotInfoService;
|
||||
|
||||
// Edge reservations: EdgeId -> List of reservations
|
||||
private readonly Dictionary<Guid, List<EdgeReservation>> _edgeReservations = [];
|
||||
private readonly Lock _reservationsLock = new();
|
||||
|
||||
/// <summary>
|
||||
/// Calculate reservation duration based on edge length and robot speed
|
||||
/// </summary>
|
||||
private static TimeSpan CalculateReservationDuration(
|
||||
double edgeLength,
|
||||
double? maxSpeed = null,
|
||||
double defaultSpeed = 1.0) // Default speed: 1.0 m/s
|
||||
{
|
||||
var speed = maxSpeed ?? defaultSpeed;
|
||||
if (speed <= 0)
|
||||
{
|
||||
speed = defaultSpeed;
|
||||
}
|
||||
|
||||
// Add buffer time (10% of travel time) for safety
|
||||
var travelTime = edgeLength / speed;
|
||||
var bufferTime = travelTime * 0.1;
|
||||
var totalTime = travelTime + bufferTime;
|
||||
|
||||
return TimeSpan.FromSeconds(Math.Max(totalTime, 0.5)); // Minimum 0.5 seconds
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Calculate edge length from start and end nodes (Euclidean distance)
|
||||
/// If trajectory exists, use more accurate calculation (future enhancement)
|
||||
/// </summary>
|
||||
private static double CalculateEdgeLength(
|
||||
RobotNet10.MapManager.Data.Node startNode,
|
||||
RobotNet10.MapManager.Data.Node endNode)
|
||||
{
|
||||
var dx = endNode.X - startNode.X;
|
||||
var dy = endNode.Y - startNode.Y;
|
||||
return Math.Sqrt(dx * dx + dy * dy);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get levelId for a robot from Robot.MapId in database
|
||||
/// </summary>
|
||||
private async Task<Guid?> GetLevelIdForRobotAsync(string robotId)
|
||||
{
|
||||
try
|
||||
{
|
||||
// Use IServiceScopeFactory to create a scope for Scoped services
|
||||
using var scope = _serviceScopeFactory.CreateScope();
|
||||
var robotService = scope.ServiceProvider.GetRequiredService<IRobotService>();
|
||||
|
||||
var robot = await robotService.GetByRobotIdAsync(robotId);
|
||||
if (robot == null)
|
||||
{
|
||||
_logger.Warning($"Robot {robotId} not found in database");
|
||||
return null;
|
||||
}
|
||||
|
||||
if (robot.MapId == null)
|
||||
{
|
||||
_logger.Warning($"Robot {robotId} has no MapId assigned");
|
||||
return null;
|
||||
}
|
||||
|
||||
// Robot.MapId is the levelId (LayoutLevel.Id)
|
||||
return robot.MapId;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Error($"Error getting levelId for robot {robotId}: {ex.Message}");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<bool> ReserveEdgesAsync(
|
||||
string robotId,
|
||||
string orderId,
|
||||
List<RouteSegment> segments,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (segments == null || segments.Count == 0)
|
||||
{
|
||||
return true; // No segments to reserve
|
||||
}
|
||||
|
||||
var reservations = new List<EdgeReservation>();
|
||||
var now = DateTime.UtcNow;
|
||||
|
||||
// Get robot info for speed calculation
|
||||
var robotInfo = await _robotInfoService.GetRobotInfoAsync(robotId, cancellationToken);
|
||||
var defaultSpeed = 1.0; // Default 1.0 m/s if robot info not available
|
||||
|
||||
// Get levelId to fetch edge details
|
||||
var levelId = await GetLevelIdForRobotAsync(robotId);
|
||||
if (levelId == null)
|
||||
{
|
||||
_logger.Warning($"Cannot get levelId for robot {robotId} to reserve edges");
|
||||
return false;
|
||||
}
|
||||
|
||||
// Get edges with nodes for length calculation
|
||||
|
||||
using var scope = _serviceScopeFactory.CreateAsyncScope();
|
||||
var _nodeService = scope.ServiceProvider.GetRequiredService<INodeService>();
|
||||
var _edgeService = scope.ServiceProvider.GetRequiredService<IEdgeService>();
|
||||
var edges = await _edgeService.GetEdgesByLevelAsync(levelId.Value, includeNodes: true, includeVehicleProperties: true);
|
||||
var nodes = await _nodeService.GetNodesByLevelAsync(levelId.Value, includeVehicleProperties: true);
|
||||
|
||||
// Create lookup dictionaries
|
||||
var edgeDict = edges.ToDictionary(e => e.Id);
|
||||
var nodeDict = nodes.ToDictionary(n => n.Id);
|
||||
|
||||
// Reserve edges from segments
|
||||
foreach (var segment in segments)
|
||||
{
|
||||
if (segment.VdaEdge == null) continue; // Skip node segments
|
||||
|
||||
// Skip virtual edges (created when robot is on edge, not in database)
|
||||
if (segment.VdaEdge.EdgeId.StartsWith("VIRTUAL_", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
_logger.Debug($"Skipping virtual edge {segment.EdgeId} for reservation (robot is on edge)");
|
||||
continue;
|
||||
}
|
||||
|
||||
if(segment.EdgeId is null) continue;
|
||||
|
||||
var edgeId = segment.EdgeId.Value;
|
||||
if (!edgeDict.TryGetValue(edgeId, out var edge))
|
||||
{
|
||||
_logger.Warning($"Edge {edgeId} not found for reservation");
|
||||
continue;
|
||||
}
|
||||
|
||||
// Get start and end nodes
|
||||
if (!nodeDict.TryGetValue(edge.StartNodeId, out var startNode) ||
|
||||
!nodeDict.TryGetValue(edge.EndNodeId, out var endNode))
|
||||
{
|
||||
_logger.Warning($"Start or end node not found for edge {edgeId}");
|
||||
continue;
|
||||
}
|
||||
|
||||
// Calculate edge length
|
||||
var edgeLength = CalculateEdgeLength(startNode, endNode);
|
||||
|
||||
// Get max speed from EdgeVehicleProperty (if available)
|
||||
// TODO: Match with robot's vehicle type
|
||||
double? maxSpeed = null;
|
||||
if (edge.VehicleProperties != null && edge.VehicleProperties.Count != 0)
|
||||
{
|
||||
// Use first vehicle property's max speed (in future, match with robot's vehicle type)
|
||||
maxSpeed = edge.VehicleProperties.FirstOrDefault()?.MaxSpeed;
|
||||
}
|
||||
|
||||
// Calculate reservation duration
|
||||
var duration = CalculateReservationDuration(edgeLength, maxSpeed, defaultSpeed);
|
||||
|
||||
var reservation = new EdgeReservation
|
||||
{
|
||||
EdgeId = edgeId,
|
||||
EdgeIdString = edge.EdgeId,
|
||||
StartNodeId = edge.StartNodeId,
|
||||
EndNodeId = edge.EndNodeId,
|
||||
RobotId = robotId,
|
||||
OrderId = orderId,
|
||||
ReservedAt = now,
|
||||
ReservedUntil = now.Add(duration),
|
||||
Status = ReservationStatus.Reserved
|
||||
};
|
||||
|
||||
reservations.Add(reservation);
|
||||
}
|
||||
|
||||
// Add reservations to dictionary (thread-safe)
|
||||
lock (_reservationsLock)
|
||||
{
|
||||
foreach (var reservation in reservations)
|
||||
{
|
||||
if (!_edgeReservations.TryGetValue(reservation.EdgeId, out List<EdgeReservation>? value))
|
||||
{
|
||||
value = [];
|
||||
_edgeReservations[reservation.EdgeId] = value;
|
||||
}
|
||||
|
||||
value.Add(reservation);
|
||||
}
|
||||
}
|
||||
|
||||
_logger.Debug($"Reserved {reservations.Count} edges for robot {robotId}, order {orderId}");
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Error($"Error reserving edges for robot {robotId}: {ex.Message}");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<bool> ReleaseReservationsAsync(
|
||||
string robotId,
|
||||
string? orderId = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
var releasedCount = 0;
|
||||
|
||||
lock (_reservationsLock)
|
||||
{
|
||||
foreach (var edgeReservations in _edgeReservations.Values)
|
||||
{
|
||||
for (int i = edgeReservations.Count - 1; i >= 0; i--)
|
||||
{
|
||||
var reservation = edgeReservations[i];
|
||||
if (reservation.RobotId == robotId &&
|
||||
(orderId == null || reservation.OrderId == orderId))
|
||||
{
|
||||
reservation.Status = ReservationStatus.Released;
|
||||
edgeReservations.RemoveAt(i);
|
||||
releasedCount++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Clean up empty lists
|
||||
var emptyEdges = _edgeReservations
|
||||
.Where(kvp => kvp.Value.Count == 0)
|
||||
.Select(kvp => kvp.Key)
|
||||
.ToList();
|
||||
|
||||
foreach (var edgeId in emptyEdges)
|
||||
{
|
||||
_edgeReservations.Remove(edgeId);
|
||||
}
|
||||
}
|
||||
|
||||
_logger.Debug($"Released {releasedCount} reservations for robot {robotId}" +
|
||||
(orderId != null ? $", order {orderId}" : ""));
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Error($"Error releasing reservations for robot {robotId}: {ex.Message}");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<List<EdgeReservation>> GetEdgeReservationsAsync(
|
||||
Guid edgeId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
lock (_reservationsLock)
|
||||
{
|
||||
if (_edgeReservations.TryGetValue(edgeId, out var reservations))
|
||||
{
|
||||
// Filter out expired reservations
|
||||
var now = DateTime.UtcNow;
|
||||
var activeReservations = reservations
|
||||
.Where(r => r.ReservedUntil > now && r.Status == ReservationStatus.Reserved)
|
||||
.ToList();
|
||||
|
||||
// Remove expired reservations
|
||||
var expiredCount = reservations.Count - activeReservations.Count;
|
||||
if (expiredCount > 0)
|
||||
{
|
||||
_edgeReservations[edgeId] = activeReservations;
|
||||
_logger.Debug($"Cleaned up {expiredCount} expired reservations for edge {edgeId}");
|
||||
}
|
||||
|
||||
return [.. activeReservations];
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Error($"Error getting reservations for edge {edgeId}: {ex.Message}");
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<bool> IsEdgeAvailableAsync(
|
||||
Guid edgeId,
|
||||
DateTime fromTime,
|
||||
DateTime toTime,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
var reservations = await GetEdgeReservationsAsync(edgeId, cancellationToken);
|
||||
|
||||
// Check if any reservation overlaps with the requested time range
|
||||
foreach (var reservation in reservations)
|
||||
{
|
||||
// Check for overlap: reservation.ReservedAt < toTime && reservation.ReservedUntil > fromTime
|
||||
if (reservation.ReservedAt < toTime && reservation.ReservedUntil > fromTime)
|
||||
{
|
||||
return false; // Edge is reserved during this time
|
||||
}
|
||||
}
|
||||
|
||||
return true; // Edge is available
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Error($"Error checking edge availability for edge {edgeId}: {ex.Message}");
|
||||
return false; // Assume not available on error
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
using RobotNet10.FleetManager.Services.TrafficControl.Models;
|
||||
|
||||
namespace RobotNet10.FleetManager.Services.TrafficControl.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Service for managing Base/Horizon segments
|
||||
/// </summary>
|
||||
public interface IBaseHorizonManagementService
|
||||
{
|
||||
/// <summary>
|
||||
/// Check robot position (Base vs Horizon)
|
||||
/// </summary>
|
||||
Task<(bool IsInBase, int CurrentSegmentIndex, RouteSegment? CurrentSegment)> CheckRobotPositionAsync(
|
||||
string robotId,
|
||||
RobotRoute route,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Release horizon segments to base when safe
|
||||
/// </summary>
|
||||
Task<bool> ReleaseHorizonSegmentAsync(
|
||||
string robotId,
|
||||
int segmentCount,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Check and release horizons for all robots (called periodically or on state update)
|
||||
/// </summary>
|
||||
Task CheckAndReleaseHorizonsAsync(
|
||||
string? robotId = null,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Calculate safe base size - largest base size without conflicts
|
||||
/// </summary>
|
||||
Task<int> CalculateSafeBaseSizeAsync(
|
||||
string robotId,
|
||||
RobotRoute route,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Count remaining Base segments based on robot's current position
|
||||
/// </summary>
|
||||
int CountRemainingBaseSegments(RobotRoute route, RobotNet.VDA5050.State.StateMsg? state);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
using RobotNet10.FleetManager.Services.TrafficControl.Models;
|
||||
|
||||
namespace RobotNet10.FleetManager.Services.TrafficControl.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Service for detecting conflicts between robots
|
||||
/// </summary>
|
||||
public interface IConflictDetectionService
|
||||
{
|
||||
/// <summary>
|
||||
/// Detect all conflicts between active robots
|
||||
/// </summary>
|
||||
Task<List<Conflict>> DetectConflictsAsync(CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Check conflicts for specific route segments
|
||||
/// </summary>
|
||||
Task<bool> CheckConflictsForSegmentsAsync(
|
||||
string robotId,
|
||||
List<RouteSegment> segments,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
using RobotNet10.FleetManager.Services.TrafficControl.Models;
|
||||
|
||||
namespace RobotNet10.FleetManager.Services.TrafficControl.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Service for resolving conflicts between robots
|
||||
/// </summary>
|
||||
public interface IConflictResolutionService
|
||||
{
|
||||
/// <summary>
|
||||
/// Resolve a conflict
|
||||
/// </summary>
|
||||
Task<bool> ResolveConflictAsync(Conflict conflict, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Evaluate conflicts for resolution optimization
|
||||
/// </summary>
|
||||
Task<List<Conflict>> EvaluateConflictsForResolutionAsync(
|
||||
List<Conflict> conflicts,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
using RobotNet10.FleetManager.Services.TrafficControl.Models;
|
||||
|
||||
namespace RobotNet10.FleetManager.Services.TrafficControl.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Service for managing edge reservations
|
||||
/// </summary>
|
||||
public interface IEdgeReservationService
|
||||
{
|
||||
/// <summary>
|
||||
/// Reserve edges for a robot's route segments
|
||||
/// </summary>
|
||||
Task<bool> ReserveEdgesAsync(
|
||||
string robotId,
|
||||
string orderId,
|
||||
List<RouteSegment> segments,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Release reservations for a robot
|
||||
/// </summary>
|
||||
Task<bool> ReleaseReservationsAsync(
|
||||
string robotId,
|
||||
string? orderId = null,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Get all reservations for a specific edge
|
||||
/// </summary>
|
||||
Task<List<EdgeReservation>> GetEdgeReservationsAsync(
|
||||
Guid edgeId,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Check if an edge is available for reservation at a given time
|
||||
/// </summary>
|
||||
Task<bool> IsEdgeAvailableAsync(
|
||||
Guid edgeId,
|
||||
DateTime fromTime,
|
||||
DateTime toTime,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
using RobotNet10.FleetManager.Services.TrafficControl.Models;
|
||||
|
||||
namespace RobotNet10.FleetManager.Services.TrafficControl.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Service for generating and sending OrderUpdate messages
|
||||
/// </summary>
|
||||
public interface IOrderUpdateService
|
||||
{
|
||||
/// <summary>
|
||||
/// Generate OrderUpdate from Horizon segments and send to robot
|
||||
/// </summary>
|
||||
Task<bool> GenerateAndSendOrderUpdateAsync(
|
||||
string robotId,
|
||||
RobotRoute route,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Send OrderUpdate to robot (general method for adding new segments)
|
||||
/// </summary>
|
||||
Task<bool> SendOrderUpdateAsync(
|
||||
string robotId,
|
||||
List<RouteSegment> newSegments,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Send initial Order to robot (Base segments only)
|
||||
/// </summary>
|
||||
Task<bool> SendInitialOrderAsync(
|
||||
string robotId,
|
||||
RobotRoute route,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
using RobotNet10.FleetManager.Services.TrafficControl.Models;
|
||||
|
||||
namespace RobotNet10.FleetManager.Services.TrafficControl.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Service for managing robot priorities
|
||||
/// </summary>
|
||||
public interface IPriorityService
|
||||
{
|
||||
/// <summary>
|
||||
/// Set priority for a robot
|
||||
/// </summary>
|
||||
Task<bool> SetRobotPriorityAsync(string robotId, RobotPriority priority);
|
||||
|
||||
/// <summary>
|
||||
/// Get priority for a robot
|
||||
/// </summary>
|
||||
Task<RobotPriority> GetRobotPriorityAsync(string robotId);
|
||||
|
||||
/// <summary>
|
||||
/// Remove priority for a robot (reset to default)
|
||||
/// </summary>
|
||||
Task<bool> RemoveRobotPriorityAsync(string robotId);
|
||||
|
||||
/// <summary>
|
||||
/// Clean up expired priorities (called periodically)
|
||||
/// </summary>
|
||||
void CleanupExpiredPriorities();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
using RobotNet10.FleetManager.Services.TrafficControl.Models;
|
||||
|
||||
namespace RobotNet10.FleetManager.Services.TrafficControl.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Service for managing robot information cache
|
||||
/// </summary>
|
||||
public interface IRobotInfoService
|
||||
{
|
||||
/// <summary>
|
||||
/// Get robot information (dimensions, navigation point) from RobotModel
|
||||
/// Caches the information to avoid repeated database queries
|
||||
/// </summary>
|
||||
Task<RobotInfo?> GetRobotInfoAsync(string robotId, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Clear robot info cache for a specific robot
|
||||
/// </summary>
|
||||
void ClearRobotInfoCache(string robotId);
|
||||
|
||||
/// <summary>
|
||||
/// Clear all robot info cache
|
||||
/// </summary>
|
||||
void ClearAllRobotInfoCache();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
using RobotNet10.FleetManager.Services.TrafficControl.Models;
|
||||
using RobotNet10.GlobalPathPlanner.Model;
|
||||
|
||||
namespace RobotNet10.FleetManager.Services.TrafficControl.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Service for route planning
|
||||
/// </summary>
|
||||
public interface IRoutePlanningService
|
||||
{
|
||||
/// <summary>
|
||||
/// Plan route for a robot from start to goal
|
||||
/// </summary>
|
||||
Task<RobotRoute?> PlanRouteAsync(
|
||||
string robotId,
|
||||
Guid startNodeId,
|
||||
Guid goalNodeId,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Plan route with optional constraints (angle, startDirection, finalDirection)
|
||||
/// </summary>
|
||||
Task<RobotRoute?> PlanRouteAsync(
|
||||
string robotId,
|
||||
Guid startNodeId,
|
||||
Guid goalNodeId,
|
||||
double? goalAngle,
|
||||
Orientation? startDirection,
|
||||
Orientation? finalDirection,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Plan route from current position (x, y, theta) to goal node
|
||||
/// </summary>
|
||||
Task<RobotRoute?> PlanRouteFromPositionAsync(
|
||||
string robotId,
|
||||
double x,
|
||||
double y,
|
||||
double theta,
|
||||
Guid goalNodeId,
|
||||
double? goalAngle,
|
||||
Orientation? startDirection,
|
||||
Orientation? finalDirection,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Plan route from current position (x, y, theta) to goal node
|
||||
/// </summary>
|
||||
Task<RobotRoute?> PlanRouteFromPositionACSTrafficAsync(
|
||||
string robotId,
|
||||
double x,
|
||||
double y,
|
||||
double theta,
|
||||
Guid goalNodeId,
|
||||
double? goalAngle,
|
||||
Orientation? startDirection,
|
||||
Orientation? finalDirection,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
using RobotNet10.FleetManager.Services.TrafficControl.Models;
|
||||
|
||||
namespace RobotNet10.FleetManager.Services.TrafficControl.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Service for managing active robot routes storage
|
||||
/// </summary>
|
||||
public interface IRouteStorageService
|
||||
{
|
||||
/// <summary>
|
||||
/// Get all active routes
|
||||
/// </summary>
|
||||
Task<Dictionary<string, RobotRoute>> GetAllActiveRoutesAsync();
|
||||
|
||||
/// <summary>
|
||||
/// Get route for a specific robot
|
||||
/// </summary>
|
||||
Task<RobotRoute?> GetRobotRouteAsync(string robotId);
|
||||
|
||||
/// <summary>
|
||||
/// Update robot route
|
||||
/// </summary>
|
||||
Task<bool> UpdateRobotRouteAsync(string robotId, RobotRoute route);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,405 @@
|
||||
using RobotNet10.FleetManager.Services.RobotManager;
|
||||
using RobotNet10.FleetManager.Services.TrafficControl.Models;
|
||||
|
||||
namespace RobotNet10.FleetManager.Services.TrafficControl.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Service for generating and sending OrderUpdate messages
|
||||
/// </summary>
|
||||
public class OrderUpdateService(
|
||||
Logger<OrderUpdateService> logger,
|
||||
IServiceScopeFactory serviceScopeFactory) : IOrderUpdateService
|
||||
{
|
||||
private readonly Logger<OrderUpdateService> _logger = logger;
|
||||
private readonly IServiceScopeFactory _serviceScopeFactory = serviceScopeFactory;
|
||||
|
||||
public async Task<bool> GenerateAndSendOrderUpdateAsync(
|
||||
string robotId,
|
||||
RobotRoute route,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
// Resolve IRobotManagerService lazily to avoid circular dependency
|
||||
using var scope = _serviceScopeFactory.CreateScope();
|
||||
var robotManager = scope.ServiceProvider.GetRequiredService<IRobotManagerService>();
|
||||
var robotController = robotManager.GetRobotController(robotId);
|
||||
if (robotController == null)
|
||||
{
|
||||
_logger.Warning($"Cannot send OrderUpdate: robot controller not found for robot {robotId}");
|
||||
return false;
|
||||
}
|
||||
|
||||
// Generate OrderUpdate from Horizon
|
||||
var orderUpdate = await GenerateOrderUpdateFromHorizonAsync(route, robotId);
|
||||
if (orderUpdate == null)
|
||||
{
|
||||
_logger.Warning($"Cannot send OrderUpdate: failed to generate order update for robot {robotId}");
|
||||
return false;
|
||||
}
|
||||
|
||||
// Send OrderUpdate
|
||||
var sent = await robotController.SendOrderAsync(orderUpdate, cancellationToken);
|
||||
if (sent.IsSuccess)
|
||||
{
|
||||
// Increment OrderUpdateId
|
||||
route.OrderUpdateId++;
|
||||
_logger.Info($"Sent OrderUpdate (ID: {route.OrderUpdateId}) to robot {robotId}");
|
||||
}
|
||||
|
||||
return sent.IsSuccess;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Error($"Error generating and sending OrderUpdate to robot {robotId}: {ex.Message}");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<bool> SendOrderUpdateAsync(
|
||||
string robotId,
|
||||
List<RouteSegment> newSegments,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(robotId))
|
||||
{
|
||||
_logger.Warning("Cannot send OrderUpdate: robotId is null or empty");
|
||||
return false;
|
||||
}
|
||||
|
||||
if (newSegments == null || newSegments.Count == 0)
|
||||
{
|
||||
_logger.Warning($"Cannot send OrderUpdate: no new segments provided for robot {robotId}");
|
||||
return false;
|
||||
}
|
||||
|
||||
// Resolve IRobotManagerService lazily to avoid circular dependency
|
||||
using var scope = _serviceScopeFactory.CreateScope();
|
||||
var robotManager = scope.ServiceProvider.GetRequiredService<IRobotManagerService>();
|
||||
var robotController = robotManager.GetRobotController(robotId);
|
||||
if (robotController == null)
|
||||
{
|
||||
_logger.Warning($"Cannot send OrderUpdate: robot controller not found for robot {robotId}");
|
||||
return false;
|
||||
}
|
||||
|
||||
// Get current order from RobotController
|
||||
var currentOrder = robotController.Data.Order;
|
||||
if (currentOrder == null)
|
||||
{
|
||||
_logger.Warning($"Cannot send OrderUpdate: no current order found for robot {robotId}");
|
||||
return false;
|
||||
}
|
||||
|
||||
// Create OrderUpdate (keep same orderId, increment orderUpdateId)
|
||||
var orderUpdate = new RobotNet.VDA5050.Order.OrderMsg
|
||||
{
|
||||
OrderId = currentOrder.OrderId,
|
||||
OrderUpdateId = currentOrder.OrderUpdateId + 1,
|
||||
SerialNumber = robotId,
|
||||
Timestamp = DateTime.UtcNow,
|
||||
Version = currentOrder.Version,
|
||||
Manufacturer = currentOrder.Manufacturer
|
||||
};
|
||||
|
||||
// Copy existing nodes and edges from current order
|
||||
var existingNodes = currentOrder.Nodes?.ToList() ?? [];
|
||||
var existingEdges = currentOrder.Edges?.ToList() ?? [];
|
||||
|
||||
// Get MapId for NodePosition (once for all segments)
|
||||
var mapId = await GetMapIdForRobotAsync(robotId);
|
||||
if (string.IsNullOrEmpty(mapId))
|
||||
{
|
||||
_logger.Warning($"Cannot get MapId for robot {robotId} to create NodePosition");
|
||||
return false;
|
||||
}
|
||||
|
||||
// Add new nodes and edges from segments (using VdaNode and VdaEdge directly)
|
||||
foreach (var segment in newSegments)
|
||||
{
|
||||
if (segment.VdaNode != null)
|
||||
{
|
||||
// Clone node and set released = true for new segments
|
||||
var node = CloneNode(segment.VdaNode);
|
||||
node.Released = true;
|
||||
existingNodes.Add(node);
|
||||
}
|
||||
|
||||
if (segment.VdaEdge != null)
|
||||
{
|
||||
// Clone edge and set released = true for new segments
|
||||
var edge = CloneEdge(segment.VdaEdge);
|
||||
edge.Released = true;
|
||||
existingEdges.Add(edge);
|
||||
}
|
||||
}
|
||||
|
||||
// Set arrays
|
||||
orderUpdate.Nodes = [.. existingNodes];
|
||||
orderUpdate.Edges = [.. existingEdges];
|
||||
|
||||
// Send via RobotController
|
||||
var sent = await robotController.SendOrderAsync(orderUpdate, cancellationToken);
|
||||
if (sent.IsSuccess)
|
||||
{
|
||||
_logger.Info($"Sent OrderUpdate (ID: {orderUpdate.OrderUpdateId}) to robot {robotId} with {newSegments.Count} new segments");
|
||||
}
|
||||
|
||||
return sent.IsSuccess;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Error($"Error sending OrderUpdate to robot {robotId}: {ex.Message}");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<bool> SendInitialOrderAsync(
|
||||
string robotId,
|
||||
RobotRoute route,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
// Resolve IRobotManagerService lazily to avoid circular dependency
|
||||
using var scope = _serviceScopeFactory.CreateScope();
|
||||
var robotManager = scope.ServiceProvider.GetRequiredService<IRobotManagerService>();
|
||||
var robotController = robotManager.GetRobotController(robotId);
|
||||
if (robotController == null)
|
||||
{
|
||||
_logger.Warning($"Cannot send initial Order: robot controller not found for robot {robotId}");
|
||||
return false;
|
||||
}
|
||||
|
||||
// Get MapId (LevelId) for NodePosition
|
||||
var mapId = await GetMapIdForRobotAsync(robotId);
|
||||
if (string.IsNullOrEmpty(mapId))
|
||||
{
|
||||
_logger.Warning($"Cannot get MapId for robot {robotId} to create NodePosition");
|
||||
return false;
|
||||
}
|
||||
|
||||
// Build nodes and edges from Base segments
|
||||
var nodes = new List<RobotNet.VDA5050.Order.Node>();
|
||||
var edges = new List<RobotNet.VDA5050.Order.Edge>();
|
||||
|
||||
foreach (var segment in route.Base)
|
||||
{
|
||||
if (segment.VdaNode != null)
|
||||
{
|
||||
// Node segment with full information
|
||||
// Base segments are always released
|
||||
var node = CloneNode(segment.VdaNode);
|
||||
node.Released = true; // Base segments are always released
|
||||
nodes.Add(node);
|
||||
}
|
||||
|
||||
if (segment.VdaEdge != null)
|
||||
{
|
||||
// Edge segment with full information
|
||||
var edge = CloneEdge(segment.VdaEdge);
|
||||
edge.Released = true; // Base segments are always released
|
||||
edges.Add(edge);
|
||||
}
|
||||
}
|
||||
|
||||
// Create initial Order
|
||||
var order = new RobotNet.VDA5050.Order.OrderMsg
|
||||
{
|
||||
OrderId = route.OrderId,
|
||||
OrderUpdateId = 0, // Initial order has OrderUpdateId = 0
|
||||
SerialNumber = robotId,
|
||||
Timestamp = DateTime.UtcNow,
|
||||
Nodes = [.. nodes],
|
||||
Edges = [.. edges]
|
||||
};
|
||||
|
||||
// Send via RobotController
|
||||
var sent = await robotController.SendOrderAsync(order, cancellationToken);
|
||||
if (sent.IsSuccess)
|
||||
{
|
||||
route.OrderUpdateId = 0; // Ensure OrderUpdateId is set
|
||||
_logger.Info($"Sent initial Order (ID: {route.OrderId}, UpdateID: 0) to robot {robotId} with {route.Base.Count} base segments");
|
||||
}
|
||||
|
||||
return sent.IsSuccess;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Error($"Error sending initial Order to robot {robotId}: {ex.Message}");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Generate OrderUpdate from Horizon segments
|
||||
/// </summary>
|
||||
private async Task<RobotNet.VDA5050.Order.OrderMsg?> GenerateOrderUpdateFromHorizonAsync(RobotRoute route, string robotId)
|
||||
{
|
||||
try
|
||||
{
|
||||
// Get MapId (LevelId) for NodePosition
|
||||
var mapId = await GetMapIdForRobotAsync(robotId);
|
||||
if (string.IsNullOrEmpty(mapId))
|
||||
{
|
||||
_logger.Warning($"Cannot get MapId for robot {robotId} to create NodePosition");
|
||||
return null;
|
||||
}
|
||||
|
||||
// Get last node of Base (decision point)
|
||||
var lastBaseNode = route.Base.LastOrDefault(s => s.VdaNode != null);
|
||||
if (lastBaseNode == null && route.Horizon.Count > 0)
|
||||
{
|
||||
// If no Base, use first node of Horizon
|
||||
lastBaseNode = route.Horizon.FirstOrDefault(s => s.VdaNode != null);
|
||||
}
|
||||
|
||||
if (lastBaseNode?.VdaNode == null)
|
||||
{
|
||||
_logger.Warning("Cannot generate OrderUpdate: no base node found");
|
||||
return null;
|
||||
}
|
||||
|
||||
// Build nodes and edges from Horizon
|
||||
var nodes = new List<RobotNet.VDA5050.Order.Node>();
|
||||
var edges = new List<RobotNet.VDA5050.Order.Edge>();
|
||||
|
||||
// Start with last base node (released) - Base nodes cannot have actions
|
||||
var lastBaseVdaNode = CloneNode(lastBaseNode.VdaNode);
|
||||
lastBaseVdaNode.Released = true;
|
||||
nodes.Add(lastBaseVdaNode);
|
||||
|
||||
// Add Horizon segments (only Horizon nodes can have wait actions)
|
||||
foreach (var segment in route.Horizon)
|
||||
{
|
||||
if (segment.VdaNode != null)
|
||||
{
|
||||
// Node segment with full information
|
||||
var node = CloneNode(segment.VdaNode);
|
||||
nodes.Add(node);
|
||||
}
|
||||
else if (segment.VdaEdge != null)
|
||||
{
|
||||
// Edge segment with full information
|
||||
var edge = CloneEdge(segment.VdaEdge);
|
||||
edges.Add(edge);
|
||||
}
|
||||
}
|
||||
|
||||
// Create OrderUpdate
|
||||
var orderUpdate = new RobotNet.VDA5050.Order.OrderMsg
|
||||
{
|
||||
OrderId = route.OrderId,
|
||||
OrderUpdateId = route.OrderUpdateId + 1, // Increment for new update
|
||||
Nodes = [.. nodes],
|
||||
Edges = [.. edges]
|
||||
};
|
||||
|
||||
return orderUpdate;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Error($"Error generating OrderUpdate from Horizon: {ex.Message}");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Clone VDA5050 Node (create a copy to avoid modifying original)
|
||||
/// </summary>
|
||||
private static RobotNet.VDA5050.Order.Node CloneNode(RobotNet.VDA5050.Order.Node source)
|
||||
{
|
||||
return new RobotNet.VDA5050.Order.Node
|
||||
{
|
||||
NodeId = source.NodeId,
|
||||
SequenceId = source.SequenceId,
|
||||
Released = source.Released,
|
||||
NodeDescription = source.NodeDescription,
|
||||
NodePosition = source.NodePosition is null ? null : new RobotNet.VDA5050.Order.NodePosition
|
||||
{
|
||||
X = source.NodePosition.X,
|
||||
Y = source.NodePosition.Y,
|
||||
Theta = source.NodePosition.Theta,
|
||||
AllowedDeviationXY = source.NodePosition.AllowedDeviationXY,
|
||||
AllowedDeviationTheta = source.NodePosition.AllowedDeviationTheta,
|
||||
MapId = source.NodePosition.MapId,
|
||||
MapDescription = source.NodePosition.MapDescription
|
||||
},
|
||||
Actions = [.. source.Actions] // Clone array
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Clone VDA5050 Edge (create a copy to avoid modifying original)
|
||||
/// </summary>
|
||||
private static RobotNet.VDA5050.Order.Edge CloneEdge(RobotNet.VDA5050.Order.Edge source)
|
||||
{
|
||||
return new RobotNet.VDA5050.Order.Edge
|
||||
{
|
||||
EdgeId = source.EdgeId,
|
||||
SequenceId = source.SequenceId,
|
||||
Released = source.Released,
|
||||
EdgeDescription = source.EdgeDescription,
|
||||
StartNodeId = source.StartNodeId,
|
||||
EndNodeId = source.EndNodeId,
|
||||
MaxSpeed = source.MaxSpeed,
|
||||
MaxHeight = source.MaxHeight,
|
||||
MinHeight = source.MinHeight,
|
||||
Orientation = source.Orientation,
|
||||
OrientationType = source.OrientationType,
|
||||
Direction = source.Direction,
|
||||
RotationAllowed = source.RotationAllowed,
|
||||
MaxRotationSpeed = source.MaxRotationSpeed,
|
||||
Length = source.Length,
|
||||
Trajectory = source.Trajectory == null ? null : new RobotNet.VDA5050.Order.Trajectory
|
||||
{
|
||||
Degree = source.Trajectory.Degree,
|
||||
KnotVector = [.. source.Trajectory.KnotVector], // Clone array
|
||||
ControlPoints = [.. source.Trajectory.ControlPoints.Select(cp => new RobotNet.VDA5050.Order.ControlPoint
|
||||
{
|
||||
X = cp.X,
|
||||
Y = cp.Y,
|
||||
Weight = cp.Weight
|
||||
})]
|
||||
},
|
||||
Corridor = source.Corridor == null ? null : new RobotNet.VDA5050.Order.Corridor
|
||||
{
|
||||
LeftWidth = source.Corridor.LeftWidth,
|
||||
RightWidth = source.Corridor.RightWidth,
|
||||
CorridorRefPoint = source.Corridor.CorridorRefPoint
|
||||
},
|
||||
Actions = [.. source.Actions] // Clone array
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get MapId (LevelId) for a robot to use in NodePosition
|
||||
/// </summary>
|
||||
private async Task<string> GetMapIdForRobotAsync(string robotId)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var scope = _serviceScopeFactory.CreateScope();
|
||||
var robotService = scope.ServiceProvider.GetRequiredService<IRobotService>();
|
||||
|
||||
var robot = await robotService.GetByRobotIdAsync(robotId);
|
||||
if (robot == null || robot.MapId == null)
|
||||
{
|
||||
_logger.Warning($"Robot {robotId} not found or has no MapId assigned");
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
// Robot.MapId is the LevelId, convert to string for MapId in NodePosition
|
||||
return robot.MapId.ToString() ?? "";
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Error($"Error getting MapId for robot {robotId}: {ex.Message}");
|
||||
return string.Empty;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
using RobotNet10.FleetManager.Services;
|
||||
using RobotNet10.FleetManager.Services.TrafficControl.Models;
|
||||
|
||||
namespace RobotNet10.FleetManager.Services.TrafficControl.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Service for managing robot priorities
|
||||
/// </summary>
|
||||
public class PriorityService(Logger<PriorityService> logger) : IPriorityService
|
||||
{
|
||||
private readonly Logger<PriorityService> _logger = logger;
|
||||
|
||||
// Robot priorities
|
||||
private readonly Dictionary<string, RobotPriority> _robotPriorities = [];
|
||||
private readonly Lock _prioritiesLock = new();
|
||||
|
||||
public Task<bool> SetRobotPriorityAsync(string robotId, RobotPriority priority)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(robotId))
|
||||
{
|
||||
_logger.Warning("Cannot set priority: robotId is null or empty");
|
||||
return Task.FromResult(false);
|
||||
}
|
||||
|
||||
if (priority == null)
|
||||
{
|
||||
_logger.Warning($"Cannot set priority for robot {robotId}: priority is null");
|
||||
return Task.FromResult(false);
|
||||
}
|
||||
|
||||
// Map PriorityReason to PriorityLevel if not set
|
||||
if (priority.PriorityLevel == 0 && priority.Reason != PriorityReason.Default)
|
||||
{
|
||||
priority.PriorityLevel = GetPriorityLevelFromReason(priority.Reason);
|
||||
}
|
||||
|
||||
// Ensure RobotId matches
|
||||
priority.RobotId = robotId;
|
||||
|
||||
lock (_prioritiesLock)
|
||||
{
|
||||
_robotPriorities[robotId] = priority;
|
||||
}
|
||||
|
||||
_logger.Info($"Set priority for robot {robotId}: Level={priority.PriorityLevel}, Reason={priority.Reason}");
|
||||
return Task.FromResult(true);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Error($"Error setting priority for robot {robotId}: {ex.Message}");
|
||||
return Task.FromResult(false);
|
||||
}
|
||||
}
|
||||
|
||||
public Task<RobotPriority> GetRobotPriorityAsync(string robotId)
|
||||
{
|
||||
var priority = GetRobotPriority(robotId);
|
||||
return Task.FromResult(priority);
|
||||
}
|
||||
|
||||
public Task<bool> RemoveRobotPriorityAsync(string robotId)
|
||||
{
|
||||
try
|
||||
{
|
||||
lock (_prioritiesLock)
|
||||
{
|
||||
if (_robotPriorities.Remove(robotId))
|
||||
{
|
||||
_logger.Info($"Removed priority for robot {robotId}");
|
||||
return Task.FromResult(true);
|
||||
}
|
||||
}
|
||||
|
||||
_logger.Debug($"No priority found for robot {robotId} to remove");
|
||||
// Return true even if no priority exists (graceful handling)
|
||||
return Task.FromResult(true);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Error($"Error removing priority for robot {robotId}: {ex.Message}");
|
||||
return Task.FromResult(false);
|
||||
}
|
||||
}
|
||||
|
||||
public void CleanupExpiredPriorities()
|
||||
{
|
||||
try
|
||||
{
|
||||
var now = DateTime.UtcNow;
|
||||
var expiredRobots = new List<string>();
|
||||
|
||||
lock (_prioritiesLock)
|
||||
{
|
||||
foreach (var (robotId, priority) in _robotPriorities)
|
||||
{
|
||||
if (priority.ValidUntil.HasValue && priority.ValidUntil.Value < now)
|
||||
{
|
||||
expiredRobots.Add(robotId);
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var robotId in expiredRobots)
|
||||
{
|
||||
_robotPriorities.Remove(robotId);
|
||||
_logger.Debug($"Removed expired priority for robot {robotId}");
|
||||
}
|
||||
}
|
||||
|
||||
if (expiredRobots.Count > 0)
|
||||
{
|
||||
_logger.Info($"Cleaned up {expiredRobots.Count} expired priorities");
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Error($"Error cleaning up expired priorities: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get priority level from PriorityReason
|
||||
/// </summary>
|
||||
private static int GetPriorityLevelFromReason(PriorityReason reason)
|
||||
{
|
||||
return reason switch
|
||||
{
|
||||
PriorityReason.Emergency => 100,
|
||||
PriorityReason.HighValueOrder => 50,
|
||||
PriorityReason.TimeCritical => 30,
|
||||
PriorityReason.ManualOverride => 75, // Between Emergency and HighValueOrder
|
||||
PriorityReason.Default => 0,
|
||||
_ => 0
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get robot priority (private helper method)
|
||||
/// </summary>
|
||||
private RobotPriority GetRobotPriority(string robotId)
|
||||
{
|
||||
lock (_prioritiesLock)
|
||||
{
|
||||
if (_robotPriorities.TryGetValue(robotId, out var priority))
|
||||
{
|
||||
// Check if priority has expired
|
||||
if (priority.ValidUntil.HasValue && priority.ValidUntil.Value < DateTime.UtcNow)
|
||||
{
|
||||
_logger.Debug($"Priority for robot {robotId} has expired, removing");
|
||||
_robotPriorities.Remove(robotId);
|
||||
}
|
||||
else
|
||||
{
|
||||
return priority;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Return default priority if not found
|
||||
return new RobotPriority
|
||||
{
|
||||
RobotId = robotId,
|
||||
PriorityLevel = 0,
|
||||
Reason = PriorityReason.Default
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,220 @@
|
||||
using RobotNet10.FleetManager.Events;
|
||||
using RobotNet10.FleetManager.Events.Events;
|
||||
using RobotNet10.FleetManager.Services.RobotManager;
|
||||
using RobotNet10.FleetManager.Services.TrafficControl.Models;
|
||||
|
||||
namespace RobotNet10.FleetManager.Services.TrafficControl.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Service for managing robot information cache
|
||||
/// Uses event-based cache invalidation for accuracy and performance
|
||||
/// </summary>
|
||||
public class RobotInfoService : IRobotInfoService
|
||||
{
|
||||
private readonly Logger<RobotInfoService> _logger;
|
||||
private readonly IServiceScopeFactory _serviceScopeFactory;
|
||||
private readonly IRobotEventBus? _eventBus;
|
||||
|
||||
// Robot static information cache (from RobotModel)
|
||||
// Only cache static info: Length, Width, NavigationPoint
|
||||
// Dynamic info (CurrentX, CurrentY, etc.) is always fetched fresh
|
||||
// Cache is invalidated via events when RobotModel is updated
|
||||
private readonly Dictionary<string, (double Length, double Width, double NavigationPointX, double NavigationPointY)> _staticInfoCache = [];
|
||||
private readonly Lock _staticInfoCacheLock = new();
|
||||
|
||||
public RobotInfoService(
|
||||
Logger<RobotInfoService> logger,
|
||||
IServiceScopeFactory serviceScopeFactory,
|
||||
IRobotEventBus? eventBus = null)
|
||||
{
|
||||
_logger = logger;
|
||||
_serviceScopeFactory = serviceScopeFactory;
|
||||
_eventBus = eventBus;
|
||||
|
||||
// Subscribe to events for automatic cache invalidation
|
||||
if (_eventBus != null)
|
||||
{
|
||||
_eventBus.RobotModelUpdated += OnRobotModelUpdated;
|
||||
_eventBus.RobotModelIdChanged += OnRobotModelIdChanged;
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.Warning("IRobotEventBus not available - cache invalidation via events disabled");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Event handler: RobotModel updated - invalidate cache for all affected robots
|
||||
/// </summary>
|
||||
private void OnRobotModelUpdated(object? sender, RobotModelUpdatedEvent e)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (e.AffectedRobotIds == null || e.AffectedRobotIds.Count == 0)
|
||||
{
|
||||
// If no specific robot IDs, clear all cache (safe but less efficient)
|
||||
_logger.Warning($"RobotModelUpdated event for ModelId {e.ModelId} has no AffectedRobotIds - clearing all cache");
|
||||
ClearAllRobotInfoCache();
|
||||
}
|
||||
else
|
||||
{
|
||||
// Clear cache only for affected robots
|
||||
lock (_staticInfoCacheLock)
|
||||
{
|
||||
foreach (var robotId in e.AffectedRobotIds)
|
||||
{
|
||||
if (_staticInfoCache.Remove(robotId))
|
||||
{
|
||||
_logger.Debug($"Invalidated cache for robot {robotId} due to RobotModel {e.ModelId} update");
|
||||
}
|
||||
}
|
||||
}
|
||||
_logger.Info($"Invalidated cache for {e.AffectedRobotIds.Count} robot(s) due to RobotModel {e.ModelId} update");
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Error($"Error handling RobotModelUpdated event: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Event handler: Robot's ModelId changed - invalidate cache for that robot
|
||||
/// </summary>
|
||||
private void OnRobotModelIdChanged(object? sender, RobotModelIdChangedEvent e)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(e.RobotId))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
lock (_staticInfoCacheLock)
|
||||
{
|
||||
if (_staticInfoCache.Remove(e.RobotId))
|
||||
{
|
||||
_logger.Info($"Invalidated cache for robot {e.RobotId} due to ModelId change (from {e.PreviousModelId} to {e.NewModelId})");
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.Debug($"Cache for robot {e.RobotId} was not found (may not have been cached yet)");
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Error($"Error handling RobotModelIdChanged event: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<RobotInfo?> GetRobotInfoAsync(string robotId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
// Step 1: Get static info (from cache or database)
|
||||
// Cache is invalidated via events, so if it exists, it's valid
|
||||
double length = 0, width = 0, navPointX = 0, navPointY = 0;
|
||||
bool needToFetch = true;
|
||||
|
||||
lock (_staticInfoCacheLock)
|
||||
{
|
||||
if (_staticInfoCache.TryGetValue(robotId, out var cachedStaticInfo))
|
||||
{
|
||||
// Cache exists and is valid (invalidated via events)
|
||||
length = cachedStaticInfo.Length;
|
||||
width = cachedStaticInfo.Width;
|
||||
navPointX = cachedStaticInfo.NavigationPointX;
|
||||
navPointY = cachedStaticInfo.NavigationPointY;
|
||||
needToFetch = false;
|
||||
_logger.Debug($"Using cached static info for robot {robotId}");
|
||||
}
|
||||
}
|
||||
|
||||
// If cache miss, fetch from database
|
||||
if (needToFetch)
|
||||
{
|
||||
using var scope = _serviceScopeFactory.CreateScope();
|
||||
var robotService = scope.ServiceProvider.GetRequiredService<IRobotService>();
|
||||
var robotModelService = scope.ServiceProvider.GetRequiredService<IRobotModelService>();
|
||||
|
||||
// Get robot from database
|
||||
var robot = await robotService.GetByRobotIdAsync(robotId);
|
||||
if (robot == null)
|
||||
{
|
||||
_logger.Warning($"Robot {robotId} not found in database");
|
||||
return null;
|
||||
}
|
||||
|
||||
// Get robot model
|
||||
var robotModel = await robotModelService.GetByIdAsync(robot.ModelId);
|
||||
if (robotModel == null)
|
||||
{
|
||||
_logger.Warning($"RobotModel {robot.ModelId} not found for robot {robotId}");
|
||||
return null;
|
||||
}
|
||||
|
||||
// Extract static info
|
||||
length = robotModel.Length;
|
||||
width = robotModel.Width;
|
||||
navPointX = robotModel.NavigationPointX;
|
||||
navPointY = robotModel.NavigationPointY;
|
||||
|
||||
// Cache static info (no timestamp needed - invalidated via events)
|
||||
lock (_staticInfoCacheLock)
|
||||
{
|
||||
_staticInfoCache[robotId] = (length, width, navPointX, navPointY);
|
||||
}
|
||||
|
||||
_logger.Debug($"Fetched and cached fresh static info for robot {robotId}");
|
||||
}
|
||||
|
||||
// Step 2: Get dynamic info (ALWAYS fresh from RobotManager - never cached)
|
||||
// Resolve IRobotManagerService lazily to avoid circular dependency
|
||||
using var scopeForRobotManager = _serviceScopeFactory.CreateScope();
|
||||
var robotManager = scopeForRobotManager.ServiceProvider.GetRequiredService<IRobotManagerService>();
|
||||
var robotController = robotManager.GetRobotController(robotId);
|
||||
var currentState = robotController?.Data?.State;
|
||||
|
||||
// Create RobotInfo with static info (cached) + dynamic info (fresh)
|
||||
var robotInfo = new RobotInfo
|
||||
{
|
||||
RobotId = robotId,
|
||||
Length = length,
|
||||
Width = width,
|
||||
NavigationPointX = navPointX,
|
||||
NavigationPointY = navPointY,
|
||||
// Dynamic info - always fresh, never cached
|
||||
CurrentX = currentState?.AgvPosition?.X ?? 0.0,
|
||||
CurrentY = currentState?.AgvPosition?.Y ?? 0.0,
|
||||
CurrentTheta = currentState?.AgvPosition?.Theta ?? 0.0,
|
||||
LastNodeId = currentState?.LastNodeId ?? string.Empty
|
||||
};
|
||||
|
||||
_logger.Debug($"Retrieved robot info for {robotId}: Length={robotInfo.Length}, Width={robotInfo.Width}, NavPoint=({robotInfo.NavigationPointX}, {robotInfo.NavigationPointY}), Position=({robotInfo.CurrentX}, {robotInfo.CurrentY})");
|
||||
|
||||
return robotInfo;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Error($"Error getting robot info for robot {robotId}: {ex.Message}");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public void ClearRobotInfoCache(string robotId)
|
||||
{
|
||||
lock (_staticInfoCacheLock)
|
||||
{
|
||||
_staticInfoCache.Remove(robotId);
|
||||
}
|
||||
}
|
||||
|
||||
public void ClearAllRobotInfoCache()
|
||||
{
|
||||
lock (_staticInfoCacheLock)
|
||||
{
|
||||
_staticInfoCache.Clear();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,649 @@
|
||||
using RobotNet10.FleetManager.Data;
|
||||
using RobotNet10.FleetManager.Services.ConfigManager;
|
||||
using RobotNet10.FleetManager.Services.RobotManager;
|
||||
using RobotNet10.FleetManager.Services.TrafficControl.ACS;
|
||||
using RobotNet10.FleetManager.Services.TrafficControl.Helpers;
|
||||
using RobotNet10.FleetManager.Services.TrafficControl.Models;
|
||||
using RobotNet10.FleetManager.Shared.Enums;
|
||||
using RobotNet10.GlobalPathPlanner;
|
||||
using RobotNet10.GlobalPathPlanner.Model;
|
||||
using RobotNet10.MapManager.Data;
|
||||
|
||||
namespace RobotNet10.FleetManager.Services.TrafficControl.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Service for route planning
|
||||
/// </summary>
|
||||
public class RoutePlanningService(
|
||||
Logger<RoutePlanningService> logger,
|
||||
IPathPlannerFactory pathPlannerFactory,
|
||||
IServiceScopeFactory serviceScopeFactory,
|
||||
IBaseHorizonManagementService baseHorizonManagementService,
|
||||
IEdgeReservationService edgeReservationService,
|
||||
IRouteStorageService routeStorageService,
|
||||
IOrderUpdateService orderUpdateService,
|
||||
IOrderControlService orderACSControl,
|
||||
ITrafficConfig trafficConfig) : IRoutePlanningService
|
||||
{
|
||||
private readonly Logger<RoutePlanningService> _logger = logger;
|
||||
private readonly IPathPlannerFactory _pathPlannerFactory = pathPlannerFactory;
|
||||
private readonly IServiceScopeFactory _serviceScopeFactory = serviceScopeFactory;
|
||||
private readonly IBaseHorizonManagementService _baseHorizonManagementService = baseHorizonManagementService;
|
||||
private readonly IEdgeReservationService _edgeReservationService = edgeReservationService;
|
||||
private readonly IRouteStorageService _routeStorageService = routeStorageService;
|
||||
private readonly IOrderUpdateService _orderUpdateService = orderUpdateService;
|
||||
private readonly IOrderControlService _orderACSControl = orderACSControl;
|
||||
private readonly ITrafficConfig _trafficConfig = trafficConfig;
|
||||
|
||||
public async Task<RobotRoute?> PlanRouteAsync(
|
||||
string robotId,
|
||||
Guid startNodeId,
|
||||
Guid goalNodeId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
return await PlanRouteAsync(robotId, startNodeId, goalNodeId, null, null, null, cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<RobotRoute?> PlanRouteAsync(
|
||||
string robotId,
|
||||
Guid startNodeId,
|
||||
Guid goalNodeId,
|
||||
double? goalAngle,
|
||||
Orientation? startDirection,
|
||||
Orientation? finalDirection,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
_logger.Info($"PlanRouteAsync called for robot {robotId} from {startNodeId} to {goalNodeId}" +
|
||||
(goalAngle.HasValue ? $", goalAngle={goalAngle}°" : "") +
|
||||
(startDirection.HasValue ? $", startDirection={startDirection}" : "") +
|
||||
(finalDirection.HasValue ? $", finalDirection={finalDirection}" : ""));
|
||||
|
||||
// 1. Get robot current state
|
||||
// Resolve IRobotManagerService lazily to avoid circular dependency
|
||||
using var scopeForRobotManager = _serviceScopeFactory.CreateScope();
|
||||
var robotManager = scopeForRobotManager.ServiceProvider.GetRequiredService<IRobotManagerService>();
|
||||
var robotController = robotManager.GetRobotController(robotId);
|
||||
if (robotController == null)
|
||||
{
|
||||
_logger.Warning($"Robot {robotId} not found");
|
||||
return null;
|
||||
}
|
||||
|
||||
var currentState = robotController.Data.State;
|
||||
if (currentState == null)
|
||||
{
|
||||
_logger.Warning($"Robot {robotId} has no state");
|
||||
return null;
|
||||
}
|
||||
|
||||
// Check AgvPosition - must not be null
|
||||
if (currentState.AgvPosition == null)
|
||||
{
|
||||
_logger.Warning($"Robot {robotId} has no AgvPosition in state");
|
||||
return null;
|
||||
}
|
||||
|
||||
// 2. Get levelId, vehicleTypeId, robotModelId, and navigationType
|
||||
var (levelId, vehicleTypeId, robotModelId, navigationType) = await GetLevelIdAndVehicleTypeIdForRobotAsync(robotId);
|
||||
if (levelId == null)
|
||||
{
|
||||
_logger.Error($"Cannot determine levelId for robot {robotId}");
|
||||
return null;
|
||||
}
|
||||
|
||||
// If vehicleTypeId is null, treat it like levelId null (return null)
|
||||
if (!vehicleTypeId.HasValue)
|
||||
{
|
||||
_logger.Error($"Cannot determine vehicleTypeId for robot {robotId}");
|
||||
return null;
|
||||
}
|
||||
|
||||
// NavigationType must be set
|
||||
if (!navigationType.HasValue)
|
||||
{
|
||||
_logger.Error($"Cannot determine NavigationType for robot {robotId}");
|
||||
return null;
|
||||
}
|
||||
|
||||
// 3. Get graph data from MapManager using RobotModelMapService
|
||||
using var scope = _serviceScopeFactory.CreateScope();
|
||||
var robotModelMapService = scope.ServiceProvider.GetRequiredService<IRobotModelMapService>();
|
||||
|
||||
List<Node> nodes;
|
||||
List<Edge> edges;
|
||||
|
||||
try
|
||||
{
|
||||
_logger.Info($"Getting filtered nodes and edges for robot {robotId} (RobotModelId={robotModelId}, LevelId={levelId.Value}, VehicleTypeId={vehicleTypeId.Value})");
|
||||
|
||||
nodes = await robotModelMapService.GetFilteredNodesByLevelAsync(robotModelId, levelId.Value);
|
||||
edges = await robotModelMapService.GetFilteredEdgesByLevelAsync(robotModelId, levelId.Value);
|
||||
|
||||
_logger.Info($"Found {nodes.Count} nodes and {edges.Count} edges filtered by VehicleTypeId={vehicleTypeId.Value}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Error($"Error getting filtered nodes/edges for robot {robotId}: {ex.Message}");
|
||||
return null;
|
||||
}
|
||||
|
||||
if (nodes.Count == 0 || edges.Count == 0)
|
||||
{
|
||||
_logger.Warning($"No nodes or edges found for level {levelId}");
|
||||
return null;
|
||||
}
|
||||
|
||||
// 4. Convert to GlobalNode and GlobalEdge
|
||||
var globalNodes = MapDataConverter.ToGlobalNodes(nodes, levelId.Value);
|
||||
var globalEdges = MapDataConverter.ToGlobalEdges(edges, levelId.Value, vehicleTypeId.Value);
|
||||
|
||||
// 5. Create planner based on NavigationType (already retrieved above)
|
||||
IPathPlanner planner = navigationType.Value switch
|
||||
{
|
||||
NavigationType.Differential => _pathPlannerFactory.CreateDifferentialPlanner(),
|
||||
NavigationType.Forklift => _pathPlannerFactory.CreateForkliftPlanner(),
|
||||
NavigationType.OmniDrive => _pathPlannerFactory.CreateOmniDrivePlanner(),
|
||||
_ => _pathPlannerFactory.CreateDifferentialPlanner() // Default fallback
|
||||
};
|
||||
|
||||
_logger.Info($"Using {navigationType.Value} planner for robot {robotId}");
|
||||
planner.SetData(globalNodes, globalEdges);
|
||||
|
||||
// 6. Get path planning method from config based on NavigationType
|
||||
var pathPlanningConfig = _trafficConfig.GetTrafficControlConfig().PathPlanning;
|
||||
var planningMethod = pathPlanningConfig.NavigationTypeMethodMapping.TryGetValue(navigationType.Value, out var method)
|
||||
? method
|
||||
: pathPlanningConfig.DefaultMethod;
|
||||
|
||||
_logger.Info($"Using path planning method {planningMethod} for NavigationType {navigationType.Value} (robot {robotId})");
|
||||
|
||||
// 7. Calculate path using selected method with optional constraints
|
||||
// AgvPosition is already checked to be not null above
|
||||
var currentTheta = currentState.AgvPosition.Theta; // radians, convert to degrees if needed
|
||||
var thetaDegrees = currentTheta * 180.0 / Math.PI;
|
||||
|
||||
var (pathNodes, pathEdges) = ExecutePathPlanning(
|
||||
planner,
|
||||
planningMethod,
|
||||
currentState.AgvPosition.X,
|
||||
currentState.AgvPosition.Y,
|
||||
thetaDegrees,
|
||||
goalNodeId,
|
||||
goalAngle,
|
||||
startDirection,
|
||||
finalDirection,
|
||||
cancellationToken);
|
||||
|
||||
if (pathNodes.Length == 0)
|
||||
{
|
||||
_logger.Warning($"No path found from position ({currentState.AgvPosition.X:F2}, {currentState.AgvPosition.Y:F2}) to {goalNodeId}");
|
||||
return null;
|
||||
}
|
||||
|
||||
// 8. Convert A* result to RobotRoute with vehicleTypeId for VehicleProperties
|
||||
var mapIdString = levelId.Value.ToString();
|
||||
var route = RouteConverter.ConvertToRobotRoute(robotId, pathNodes, pathEdges, nodes, edges, goalAngle, _logger, vehicleTypeId, mapIdString);
|
||||
|
||||
// 9. Calculate safe base size (without conflicts)
|
||||
var safeBaseSize = await _baseHorizonManagementService.CalculateSafeBaseSizeAsync(robotId, route, cancellationToken);
|
||||
|
||||
// Ensure at least 1 segment in Horizon
|
||||
if (safeBaseSize >= route.FullRoute.Count)
|
||||
{
|
||||
safeBaseSize = Math.Max(1, route.FullRoute.Count - 1);
|
||||
}
|
||||
|
||||
// 10. Split into Base and Horizon with safe base size
|
||||
RouteConverter.SplitRouteIntoBaseAndHorizon(route, safeBaseSize);
|
||||
|
||||
// 11. Reserve edges for Base segments
|
||||
var orderId = route.OrderId;
|
||||
if (string.IsNullOrEmpty(orderId))
|
||||
{
|
||||
orderId = $"ORDER_{robotId}_{DateTime.UtcNow:yyyyMMddHHmmss}";
|
||||
route.OrderId = orderId;
|
||||
}
|
||||
|
||||
var reserveSuccess = await _edgeReservationService.ReserveEdgesAsync(robotId, orderId, route.Base, cancellationToken);
|
||||
if (!reserveSuccess)
|
||||
{
|
||||
_logger.Warning($"Failed to reserve edges for robot {robotId}, but route is still created");
|
||||
}
|
||||
|
||||
// 12. Store route
|
||||
await _routeStorageService.UpdateRobotRouteAsync(robotId, route);
|
||||
|
||||
// 13. Send initial Order to robot (Base segments only, Horizon will be sent later)
|
||||
var orderSent = await _orderUpdateService.SendInitialOrderAsync(robotId, route, cancellationToken);
|
||||
if (!orderSent)
|
||||
{
|
||||
_logger.Warning($"Failed to send initial Order to robot {robotId}, but route is still stored");
|
||||
}
|
||||
|
||||
_logger.Info($"Route planned successfully for robot {robotId}: {route.Base.Count} base segments, {route.Horizon.Count} horizon segments. Edges reserved: {reserveSuccess}, Order sent: {orderSent}");
|
||||
|
||||
return route;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Error($"Error planning route for robot {robotId}: {ex.Message}");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<RobotRoute?> PlanRouteFromPositionAsync(
|
||||
string robotId,
|
||||
double x,
|
||||
double y,
|
||||
double theta,
|
||||
Guid goalNodeId,
|
||||
double? goalAngle,
|
||||
Orientation? startDirection,
|
||||
Orientation? finalDirection,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
_logger.Info($"PlanRouteFromPositionAsync called for robot {robotId} from position ({x:F2}, {y:F2}, {theta:F2}°) to {goalNodeId}" +
|
||||
(goalAngle.HasValue ? $", goalAngle={goalAngle}°" : "") +
|
||||
(startDirection.HasValue ? $", startDirection={startDirection}" : "") +
|
||||
(finalDirection.HasValue ? $", finalDirection={finalDirection}" : ""));
|
||||
|
||||
// 1. Get levelId, vehicleTypeId, robotModelId, and navigationType
|
||||
var (levelId, vehicleTypeId, robotModelId, navigationType) = await GetLevelIdAndVehicleTypeIdForRobotAsync(robotId);
|
||||
if (levelId == null)
|
||||
{
|
||||
_logger.Error($"Cannot determine levelId for robot {robotId}");
|
||||
return null;
|
||||
}
|
||||
|
||||
// If vehicleTypeId is null, treat it like levelId null (return null)
|
||||
if (!vehicleTypeId.HasValue)
|
||||
{
|
||||
_logger.Error($"Cannot determine vehicleTypeId for robot {robotId}");
|
||||
return null;
|
||||
}
|
||||
|
||||
// NavigationType must be set
|
||||
if (!navigationType.HasValue)
|
||||
{
|
||||
_logger.Error($"Cannot determine NavigationType for robot {robotId}");
|
||||
return null;
|
||||
}
|
||||
|
||||
// 2. Get graph data from MapManager using RobotModelMapService
|
||||
using var scope = _serviceScopeFactory.CreateScope();
|
||||
var robotModelMapService = scope.ServiceProvider.GetRequiredService<IRobotModelMapService>();
|
||||
|
||||
List<Node> nodes;
|
||||
List<Edge> edges;
|
||||
|
||||
try
|
||||
{
|
||||
_logger.Info($"Getting filtered nodes and edges for robot {robotId} (RobotModelId={robotModelId}, LevelId={levelId.Value}, VehicleTypeId={vehicleTypeId.Value})");
|
||||
|
||||
nodes = await robotModelMapService.GetFilteredNodesByLevelAsync(robotModelId, levelId.Value);
|
||||
edges = await robotModelMapService.GetFilteredEdgesByLevelAsync(robotModelId, levelId.Value);
|
||||
|
||||
_logger.Info($"Found {nodes.Count} nodes and {edges.Count} edges filtered by VehicleTypeId={vehicleTypeId.Value}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Error($"Error getting filtered nodes/edges for robot {robotId}: {ex.Message}");
|
||||
return null;
|
||||
}
|
||||
|
||||
if (nodes.Count == 0 || edges.Count == 0)
|
||||
{
|
||||
_logger.Warning($"No nodes or edges found for level {levelId}");
|
||||
return null;
|
||||
}
|
||||
|
||||
// 3. Convert to GlobalNode and GlobalEdge
|
||||
var globalNodes = MapDataConverter.ToGlobalNodes(nodes, levelId.Value);
|
||||
var globalEdges = MapDataConverter.ToGlobalEdges(edges, levelId.Value, vehicleTypeId.Value);
|
||||
|
||||
// 4. Create planner based on NavigationType
|
||||
IPathPlanner planner = navigationType.Value switch
|
||||
{
|
||||
NavigationType.Differential => _pathPlannerFactory.CreateDifferentialPlanner(),
|
||||
NavigationType.Forklift => _pathPlannerFactory.CreateForkliftPlanner(),
|
||||
NavigationType.OmniDrive => _pathPlannerFactory.CreateOmniDrivePlanner(),
|
||||
_ => _pathPlannerFactory.CreateDifferentialPlanner() // Default fallback
|
||||
};
|
||||
|
||||
_logger.Info($"Using {navigationType.Value} planner for robot {robotId}");
|
||||
planner.SetData(globalNodes, globalEdges);
|
||||
|
||||
// 5. Get path planning method from config based on NavigationType
|
||||
var pathPlanningConfig = _trafficConfig.GetTrafficControlConfig().PathPlanning;
|
||||
var planningMethod = pathPlanningConfig.NavigationTypeMethodMapping.TryGetValue(navigationType.Value, out var method)
|
||||
? method
|
||||
: pathPlanningConfig.DefaultMethod;
|
||||
|
||||
_logger.Info($"Using path planning method {planningMethod} for NavigationType {navigationType.Value} (robot {robotId})");
|
||||
|
||||
// 6. Calculate path using selected method with optional constraints
|
||||
// theta is already in degrees
|
||||
var (pathNodes, pathEdges) = ExecutePathPlanning(
|
||||
planner,
|
||||
planningMethod,
|
||||
x,
|
||||
y,
|
||||
theta,
|
||||
goalNodeId,
|
||||
goalAngle,
|
||||
startDirection,
|
||||
finalDirection,
|
||||
cancellationToken);
|
||||
|
||||
if (pathNodes.Length == 0)
|
||||
{
|
||||
_logger.Warning($"No path found from position ({x:F2}, {y:F2}) to {goalNodeId}");
|
||||
return null;
|
||||
}
|
||||
|
||||
// 7. Convert A* result to RobotRoute with vehicleTypeId for VehicleProperties
|
||||
var mapIdString = levelId.Value.ToString();
|
||||
var route = RouteConverter.ConvertToRobotRoute(robotId, pathNodes, pathEdges, nodes, edges, goalAngle, _logger, vehicleTypeId, mapIdString);
|
||||
|
||||
// 8. Calculate safe base size (without conflicts)
|
||||
var safeBaseSize = await _baseHorizonManagementService.CalculateSafeBaseSizeAsync(robotId, route, cancellationToken);
|
||||
|
||||
// Ensure at least 1 segment in Horizon
|
||||
if (safeBaseSize >= route.FullRoute.Count)
|
||||
{
|
||||
safeBaseSize = Math.Max(1, route.FullRoute.Count - 1);
|
||||
}
|
||||
|
||||
// 9. Split into Base and Horizon with safe base size
|
||||
RouteConverter.SplitRouteIntoBaseAndHorizon(route, route.FullRoute.Count);
|
||||
|
||||
// 10. Reserve edges for Base segments
|
||||
var orderId = route.OrderId;
|
||||
if (string.IsNullOrEmpty(orderId))
|
||||
{
|
||||
orderId = $"ORDER_{robotId}_{DateTime.UtcNow:yyyyMMddHHmmss}";
|
||||
route.OrderId = orderId;
|
||||
}
|
||||
|
||||
var reserveSuccess = await _edgeReservationService.ReserveEdgesAsync(robotId, orderId, route.Base, cancellationToken);
|
||||
if (!reserveSuccess)
|
||||
{
|
||||
_logger.Warning($"Failed to reserve edges for robot {robotId}, but route is still created");
|
||||
}
|
||||
|
||||
// 11. Store route
|
||||
await _routeStorageService.UpdateRobotRouteAsync(robotId, route);
|
||||
|
||||
// 12. Send initial Order to robot (Base segments only, Horizon will be sent later)
|
||||
var orderSent = await _orderUpdateService.SendInitialOrderAsync(robotId, route, cancellationToken);
|
||||
if (!orderSent)
|
||||
{
|
||||
_logger.Warning($"Failed to send initial Order to robot {robotId}, but route is still stored");
|
||||
}
|
||||
|
||||
_logger.Info($"Route planned successfully for robot {robotId}: {route.Base.Count} base segments, {route.Horizon.Count} horizon segments. Edges reserved: {reserveSuccess}, Order sent: {orderSent}");
|
||||
|
||||
return route;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Error($"Error planning route from position for robot {robotId}: {ex.Message}");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<RobotRoute?> PlanRouteFromPositionACSTrafficAsync(string robotId, double x, double y, double theta, Guid goalNodeId, double? goalAngle, Orientation? startDirection, Orientation? finalDirection, CancellationToken cancellationToken = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
_logger.Info($"PlanRouteFromPositionAsync called for robot {robotId} from position ({x:F2}, {y:F2}, {theta:F2}°) to {goalNodeId}" +
|
||||
(goalAngle.HasValue ? $", goalAngle={goalAngle}°" : "") +
|
||||
(startDirection.HasValue ? $", startDirection={startDirection}" : "") +
|
||||
(finalDirection.HasValue ? $", finalDirection={finalDirection}" : ""));
|
||||
|
||||
// 1. Get levelId, vehicleTypeId, robotModelId, and navigationType
|
||||
var (levelId, vehicleTypeId, robotModelId, navigationType) = await GetLevelIdAndVehicleTypeIdForRobotAsync(robotId);
|
||||
if (levelId == null)
|
||||
{
|
||||
_logger.Error($"Cannot determine levelId for robot {robotId}");
|
||||
return null;
|
||||
}
|
||||
|
||||
// If vehicleTypeId is null, treat it like levelId null (return null)
|
||||
if (!vehicleTypeId.HasValue)
|
||||
{
|
||||
_logger.Error($"Cannot determine vehicleTypeId for robot {robotId}");
|
||||
return null;
|
||||
}
|
||||
|
||||
// NavigationType must be set
|
||||
if (!navigationType.HasValue)
|
||||
{
|
||||
_logger.Error($"Cannot determine NavigationType for robot {robotId}");
|
||||
return null;
|
||||
}
|
||||
|
||||
// 2. Get graph data from MapManager using RobotModelMapService
|
||||
using var scope = _serviceScopeFactory.CreateScope();
|
||||
var robotModelMapService = scope.ServiceProvider.GetRequiredService<IRobotModelMapService>();
|
||||
|
||||
List<Node> nodes;
|
||||
List<Edge> edges;
|
||||
|
||||
try
|
||||
{
|
||||
_logger.Info($"Getting filtered nodes and edges for robot {robotId} (RobotModelId={robotModelId}, LevelId={levelId.Value}, VehicleTypeId={vehicleTypeId.Value})");
|
||||
|
||||
nodes = await robotModelMapService.GetFilteredNodesByLevelAsync(robotModelId, levelId.Value);
|
||||
edges = await robotModelMapService.GetFilteredEdgesByLevelAsync(robotModelId, levelId.Value);
|
||||
|
||||
_logger.Info($"Found {nodes.Count} nodes and {edges.Count} edges filtered by VehicleTypeId={vehicleTypeId.Value}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Error($"Error getting filtered nodes/edges for robot {robotId}: {ex.Message}");
|
||||
return null;
|
||||
}
|
||||
|
||||
if (nodes.Count == 0 || edges.Count == 0)
|
||||
{
|
||||
_logger.Warning($"No nodes or edges found for level {levelId}");
|
||||
return null;
|
||||
}
|
||||
|
||||
// 3. Convert to GlobalNode and GlobalEdge
|
||||
var globalNodes = MapDataConverter.ToGlobalNodes(nodes, levelId.Value);
|
||||
var globalEdges = MapDataConverter.ToGlobalEdges(edges, levelId.Value, vehicleTypeId.Value);
|
||||
|
||||
// 4. Create planner based on NavigationType
|
||||
IPathPlanner planner = navigationType.Value switch
|
||||
{
|
||||
NavigationType.Differential => _pathPlannerFactory.CreateDifferentialPlanner(),
|
||||
NavigationType.Forklift => _pathPlannerFactory.CreateForkliftPlanner(),
|
||||
NavigationType.OmniDrive => _pathPlannerFactory.CreateOmniDrivePlanner(),
|
||||
_ => _pathPlannerFactory.CreateDifferentialPlanner() // Default fallback
|
||||
};
|
||||
|
||||
_logger.Info($"Using {navigationType.Value} planner for robot {robotId}");
|
||||
planner.SetData(globalNodes, globalEdges);
|
||||
|
||||
// 5. Get path planning method from config based on NavigationType
|
||||
var pathPlanningConfig = _trafficConfig.GetTrafficControlConfig().PathPlanning;
|
||||
var planningMethod = pathPlanningConfig.NavigationTypeMethodMapping.TryGetValue(navigationType.Value, out var method)
|
||||
? method
|
||||
: pathPlanningConfig.DefaultMethod;
|
||||
|
||||
_logger.Info($"Using path planning method {planningMethod} for NavigationType {navigationType.Value} (robot {robotId})");
|
||||
|
||||
// 6. Calculate path using selected method with optional constraints
|
||||
// theta is already in degrees
|
||||
var (pathNodes, pathEdges) = ExecutePathPlanning(
|
||||
planner,
|
||||
planningMethod,
|
||||
x,
|
||||
y,
|
||||
theta,
|
||||
goalNodeId,
|
||||
goalAngle,
|
||||
startDirection,
|
||||
finalDirection,
|
||||
cancellationToken);
|
||||
|
||||
if (pathNodes.Length == 0)
|
||||
{
|
||||
_logger.Warning($"No path found from position ({x:F2}, {y:F2}) to {goalNodeId}");
|
||||
return null;
|
||||
}
|
||||
|
||||
// 7. Convert A* result to RobotRoute with vehicleTypeId for VehicleProperties
|
||||
var mapIdString = levelId.Value.ToString();
|
||||
var route = RouteConverter.ConvertToRobotRoute(robotId, pathNodes, pathEdges, nodes, edges, goalAngle, _logger, vehicleTypeId, mapIdString);
|
||||
|
||||
// xử lí order
|
||||
var createOrder = await _orderACSControl.CreateRobotOrderAsync(robotId, route);
|
||||
if (!createOrder)
|
||||
{
|
||||
_logger.Warning($"Failed to send initial Order to robot {robotId}");
|
||||
return null;
|
||||
}
|
||||
else _logger.Info($"Route planned successfully for robot {robotId}: {route.Base.Count} base segments");
|
||||
|
||||
return route;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Error($"Error planning route from position for robot {robotId}: {ex.Message}");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get levelId, VehicleTypeId, RobotModelId, and NavigationType for a robot from Robot.MapId and RobotModel in database
|
||||
/// </summary>
|
||||
private async Task<(Guid? levelId, Guid? vehicleTypeId, Guid robotModelId, NavigationType? navigationType)> GetLevelIdAndVehicleTypeIdForRobotAsync(string robotId)
|
||||
{
|
||||
try
|
||||
{
|
||||
// Use IServiceScopeFactory to create a scope for Scoped services
|
||||
using var scope = _serviceScopeFactory.CreateScope();
|
||||
var robotService = scope.ServiceProvider.GetRequiredService<IRobotService>();
|
||||
var appContext = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
|
||||
|
||||
var robot = await robotService.GetByRobotIdAsync(robotId);
|
||||
if (robot == null)
|
||||
{
|
||||
_logger.Warning($"Robot {robotId} not found in database");
|
||||
return (null, null, Guid.Empty, null);
|
||||
}
|
||||
|
||||
if (robot.MapId == null)
|
||||
{
|
||||
_logger.Warning($"Robot {robotId} has no MapId assigned");
|
||||
return (null, null, Guid.Empty, null);
|
||||
}
|
||||
|
||||
// Robot.MapId is the levelId (LayoutLevel.Id)
|
||||
var levelId = robot.MapId;
|
||||
|
||||
// Get VehicleTypeId and NavigationType from RobotModel
|
||||
Guid? vehicleTypeId = null;
|
||||
Guid robotModelId = robot.ModelId;
|
||||
NavigationType? navigationType = null;
|
||||
|
||||
if (robot.ModelId != Guid.Empty)
|
||||
{
|
||||
var robotModel = await appContext.RobotModels.FindAsync(robot.ModelId);
|
||||
if (robotModel != null)
|
||||
{
|
||||
navigationType = robotModel.NavigationType;
|
||||
if (robotModel.VehicleTypeId.HasValue)
|
||||
{
|
||||
vehicleTypeId = robotModel.VehicleTypeId.Value;
|
||||
_logger.Info($"Robot {robotId} has VehicleTypeId={vehicleTypeId.Value} and NavigationType={navigationType} from RobotModel {robotModel.ModelName}");
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.Info($"Robot {robotId} has no VehicleTypeId assigned in RobotModel, but NavigationType={navigationType}");
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.Info($"Robot {robotId} has no RobotModel found");
|
||||
}
|
||||
}
|
||||
|
||||
return (levelId, vehicleTypeId, robotModelId, navigationType);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Error($"Error getting levelId and VehicleTypeId for robot {robotId}: {ex.Message}");
|
||||
return (null, null, Guid.Empty, null);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Execute path planning using the specified method with optional constraints
|
||||
/// </summary>
|
||||
private (GlobalNode[] Nodes, GlobalEdge[] Edges) ExecutePathPlanning(
|
||||
IPathPlanner planner,
|
||||
PathPlanningMethod method,
|
||||
double x,
|
||||
double y,
|
||||
double theta,
|
||||
Guid goalId,
|
||||
double? goalAngle,
|
||||
Orientation? startDirection,
|
||||
Orientation? finalDirection,
|
||||
CancellationToken? cancellationToken)
|
||||
{
|
||||
// Priority: Explicit parameters > Config method > Basic
|
||||
// If explicit parameters are provided, use them regardless of config method
|
||||
|
||||
if (goalAngle.HasValue)
|
||||
{
|
||||
// Use angle constraint if provided
|
||||
_logger.Info($"Using PathPlanningWithAngle with goalAngle={goalAngle.Value}°");
|
||||
return planner.PathPlanningWithAngle(x, y, theta, goalId, goalAngle.Value, cancellationToken);
|
||||
}
|
||||
|
||||
if (finalDirection.HasValue && finalDirection.Value != Orientation.NONE)
|
||||
{
|
||||
// Use final direction constraint if provided
|
||||
_logger.Info($"Using PathPlanningWithFinalDirection with finalDirection={finalDirection.Value}");
|
||||
return planner.PathPlanningWithFinalDirection(x, y, theta, goalId, finalDirection.Value, cancellationToken);
|
||||
}
|
||||
|
||||
if (startDirection.HasValue && startDirection.Value != Orientation.NONE)
|
||||
{
|
||||
// Use start direction constraint if provided
|
||||
_logger.Info($"Using PathPlanningWithStartDirection with startDirection={startDirection.Value}");
|
||||
return planner.PathPlanningWithStartDirection(x, y, theta, goalId, startDirection.Value, cancellationToken);
|
||||
}
|
||||
|
||||
// No explicit constraints, use method from config
|
||||
switch (method)
|
||||
{
|
||||
case PathPlanningMethod.Basic:
|
||||
return planner.PathPlanning(x, y, theta, goalId, cancellationToken);
|
||||
|
||||
case PathPlanningMethod.WithStartDirection:
|
||||
return planner.PathPlanningWithStartDirection(x, y, theta, goalId, Orientation.NONE, cancellationToken);
|
||||
|
||||
case PathPlanningMethod.WithFinalDirection:
|
||||
return planner.PathPlanningWithFinalDirection(x, y, theta, goalId, Orientation.NONE, cancellationToken);
|
||||
|
||||
case PathPlanningMethod.WithAngle:
|
||||
// For WithAngle from config, use current theta as goal angle
|
||||
_logger.Info($"Using PathPlanningMethod.WithAngle from config, using current theta {theta}° as goal angle");
|
||||
return planner.PathPlanningWithAngle(x, y, theta, goalId, theta, cancellationToken);
|
||||
|
||||
default:
|
||||
return planner.PathPlanning(x, y, theta, goalId, cancellationToken);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
using RobotNet10.FleetManager.Services.TrafficControl.Models;
|
||||
|
||||
namespace RobotNet10.FleetManager.Services.TrafficControl.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Service for managing active robot routes storage
|
||||
/// </summary>
|
||||
public class RouteStorageService : IRouteStorageService
|
||||
{
|
||||
// In-memory storage for active routes
|
||||
private readonly Dictionary<string, RobotRoute> _activeRoutes = [];
|
||||
private readonly Lock _routesLock = new();
|
||||
|
||||
public Task<Dictionary<string, RobotRoute>> GetAllActiveRoutesAsync()
|
||||
{
|
||||
lock (_routesLock)
|
||||
{
|
||||
return Task.FromResult(new Dictionary<string, RobotRoute>(_activeRoutes));
|
||||
}
|
||||
}
|
||||
|
||||
public Task<RobotRoute?> GetRobotRouteAsync(string robotId)
|
||||
{
|
||||
lock (_routesLock)
|
||||
{
|
||||
_activeRoutes.TryGetValue(robotId, out var route);
|
||||
return Task.FromResult(route);
|
||||
}
|
||||
}
|
||||
|
||||
public Task<bool> UpdateRobotRouteAsync(string robotId, RobotRoute route)
|
||||
{
|
||||
try
|
||||
{
|
||||
lock (_routesLock)
|
||||
{
|
||||
_activeRoutes[robotId] = route;
|
||||
}
|
||||
return Task.FromResult(true);
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
return Task.FromResult(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,377 @@
|
||||
using RobotNet10.FleetManager.Events;
|
||||
using RobotNet10.FleetManager.Events.Events;
|
||||
using RobotNet10.FleetManager.Services.ConfigManager;
|
||||
using RobotNet10.FleetManager.Services.TrafficControl.Models;
|
||||
using RobotNet10.FleetManager.Services.TrafficControl.Services;
|
||||
|
||||
namespace RobotNet10.FleetManager.Services.TrafficControl;
|
||||
|
||||
/// <summary>
|
||||
/// Orchestrator service for traffic control and conflict management between robots
|
||||
/// Delegates to specialized sub-services for actual implementation
|
||||
/// </summary>
|
||||
public class TrafficControlService : BackgroundService, ITrafficControlService
|
||||
{
|
||||
private readonly Logger<TrafficControlService> _logger;
|
||||
private readonly ITrafficConfig _trafficConfig;
|
||||
private readonly IRobotEventBus? _eventBus;
|
||||
|
||||
// Sub-services (injected via constructor)
|
||||
private readonly IRoutePlanningService _routePlanningService;
|
||||
private readonly IConflictDetectionService _conflictDetectionService;
|
||||
private readonly IConflictResolutionService _conflictResolutionService;
|
||||
private readonly IBaseHorizonManagementService _baseHorizonManagementService;
|
||||
private readonly IEdgeReservationService _edgeReservationService;
|
||||
private readonly IPriorityService _priorityService;
|
||||
private readonly IRobotInfoService _robotInfoService;
|
||||
private readonly IRouteStorageService _routeStorageService;
|
||||
private readonly IOrderUpdateService _orderUpdateService;
|
||||
|
||||
public TrafficControlService(
|
||||
Logger<TrafficControlService> logger,
|
||||
ITrafficConfig trafficConfig,
|
||||
IRobotEventBus? eventBus,
|
||||
IRoutePlanningService routePlanningService,
|
||||
IConflictDetectionService conflictDetectionService,
|
||||
IConflictResolutionService conflictResolutionService,
|
||||
IBaseHorizonManagementService baseHorizonManagementService,
|
||||
IEdgeReservationService edgeReservationService,
|
||||
IPriorityService priorityService,
|
||||
IRobotInfoService robotInfoService,
|
||||
IRouteStorageService routeStorageService,
|
||||
IOrderUpdateService orderUpdateService)
|
||||
{
|
||||
_logger = logger;
|
||||
_trafficConfig = trafficConfig ?? throw new ArgumentNullException(nameof(trafficConfig));
|
||||
_eventBus = eventBus;
|
||||
_routePlanningService = routePlanningService;
|
||||
_conflictDetectionService = conflictDetectionService;
|
||||
_conflictResolutionService = conflictResolutionService;
|
||||
_baseHorizonManagementService = baseHorizonManagementService;
|
||||
_edgeReservationService = edgeReservationService;
|
||||
_priorityService = priorityService;
|
||||
_robotInfoService = robotInfoService;
|
||||
_routeStorageService = routeStorageService;
|
||||
_orderUpdateService = orderUpdateService;
|
||||
|
||||
// Subscribe to State messages if event bus is available
|
||||
if (_eventBus != null)
|
||||
{
|
||||
_eventBus.StateMessageReceived += OnStateMessageReceived;
|
||||
_logger.Info("Subscribed to State messages for robot progress monitoring");
|
||||
}
|
||||
}
|
||||
|
||||
#region ITrafficControlService Implementation
|
||||
|
||||
public async Task<RobotRoute?> PlanRouteAsync(
|
||||
string robotId,
|
||||
Guid startNodeId,
|
||||
Guid goalNodeId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
return await _routePlanningService.PlanRouteAsync(robotId, startNodeId, goalNodeId, cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<RobotRoute?> PlanRouteAsync(
|
||||
string robotId,
|
||||
Guid startNodeId,
|
||||
Guid goalNodeId,
|
||||
double? goalAngle,
|
||||
RobotNet10.GlobalPathPlanner.Model.Orientation? startDirection,
|
||||
RobotNet10.GlobalPathPlanner.Model.Orientation? finalDirection,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
return await _routePlanningService.PlanRouteAsync(robotId, startNodeId, goalNodeId, goalAngle, startDirection, finalDirection, cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<RobotRoute?> PlanRouteFromPositionAsync(
|
||||
string robotId,
|
||||
double x,
|
||||
double y,
|
||||
double theta,
|
||||
Guid goalNodeId,
|
||||
double? goalAngle,
|
||||
RobotNet10.GlobalPathPlanner.Model.Orientation? startDirection,
|
||||
RobotNet10.GlobalPathPlanner.Model.Orientation? finalDirection,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
return await _routePlanningService.PlanRouteFromPositionAsync(robotId, x, y, theta, goalNodeId, goalAngle, startDirection, finalDirection, cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<RobotRoute?> PlanRouteFromPositionACSTrafficAsync(
|
||||
string robotId,
|
||||
double x,
|
||||
double y,
|
||||
double theta,
|
||||
Guid goalNodeId,
|
||||
double? goalAngle,
|
||||
RobotNet10.GlobalPathPlanner.Model.Orientation? startDirection,
|
||||
RobotNet10.GlobalPathPlanner.Model.Orientation? finalDirection,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
return await _routePlanningService.PlanRouteFromPositionACSTrafficAsync(robotId, x, y, theta, goalNodeId, goalAngle, startDirection, finalDirection, cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<List<Conflict>> DetectConflictsAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
return await _conflictDetectionService.DetectConflictsAsync(cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<bool> ResolveConflictAsync(Conflict conflict, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return await _conflictResolutionService.ResolveConflictAsync(conflict, cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<bool> ReleaseHorizonSegmentAsync(
|
||||
string robotId,
|
||||
int segmentCount,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
return await _baseHorizonManagementService.ReleaseHorizonSegmentAsync(robotId, segmentCount, cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<bool> UpdateRobotRouteAsync(
|
||||
string robotId,
|
||||
RobotRoute newRoute,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
return await _routeStorageService.UpdateRobotRouteAsync(robotId, newRoute);
|
||||
}
|
||||
|
||||
public async Task<Dictionary<string, RobotRoute>> GetAllActiveRoutesAsync()
|
||||
{
|
||||
var routes = await _routeStorageService.GetAllActiveRoutesAsync();
|
||||
return routes.ToDictionary(r => r.Key, r => r.Value);
|
||||
}
|
||||
|
||||
public async Task<RobotRoute?> GetRobotRouteAsync(string robotId)
|
||||
{
|
||||
return await _routeStorageService.GetRobotRouteAsync(robotId);
|
||||
}
|
||||
|
||||
public Task<bool> SetRobotPriorityAsync(string robotId, RobotPriority priority)
|
||||
{
|
||||
return _priorityService.SetRobotPriorityAsync(robotId, priority);
|
||||
}
|
||||
|
||||
public Task<RobotPriority> GetRobotPriorityAsync(string robotId)
|
||||
{
|
||||
return _priorityService.GetRobotPriorityAsync(robotId);
|
||||
}
|
||||
|
||||
public Task<bool> RemoveRobotPriorityAsync(string robotId)
|
||||
{
|
||||
return _priorityService.RemoveRobotPriorityAsync(robotId);
|
||||
}
|
||||
|
||||
public async Task<List<Conflict>> EvaluateConflictsForResolutionAsync(
|
||||
List<Conflict> conflicts,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
return await _conflictResolutionService.EvaluateConflictsForResolutionAsync(conflicts, cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<bool> SendOrderUpdateAsync(
|
||||
string robotId,
|
||||
List<RouteSegment> newSegments,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
return await _orderUpdateService.SendOrderUpdateAsync(robotId, newSegments, cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<bool> ReserveEdgesAsync(
|
||||
string robotId,
|
||||
string orderId,
|
||||
List<RouteSegment> segments,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
return await _edgeReservationService.ReserveEdgesAsync(robotId, orderId, segments, cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<List<EdgeReservation>> GetEdgeReservationsAsync(
|
||||
Guid edgeId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
return await _edgeReservationService.GetEdgeReservationsAsync(edgeId, cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<bool> IsEdgeAvailableAsync(
|
||||
Guid edgeId,
|
||||
DateTime fromTime,
|
||||
DateTime toTime,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
return await _edgeReservationService.IsEdgeAvailableAsync(edgeId, fromTime, toTime, cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<bool> ReleaseReservationsAsync(
|
||||
string robotId,
|
||||
string orderId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
return await _edgeReservationService.ReleaseReservationsAsync(robotId, orderId, cancellationToken);
|
||||
}
|
||||
|
||||
public async Task CheckAndReleaseHorizonsAsync(
|
||||
string? robotId = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
await _baseHorizonManagementService.CheckAndReleaseHorizonsAsync(robotId, cancellationToken);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region BackgroundService Implementation
|
||||
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
await Task.Yield();
|
||||
_logger.Info("TrafficControlService started");
|
||||
|
||||
var config = _trafficConfig.GetTrafficControlConfig();
|
||||
var timer = new PeriodicTimer(TimeSpan.FromMilliseconds(config.ConflictDetection.IntervalMs));
|
||||
|
||||
try
|
||||
{
|
||||
while (await timer.WaitForNextTickAsync(stoppingToken))
|
||||
{
|
||||
try
|
||||
{
|
||||
// Cleanup expired priorities periodically
|
||||
_priorityService.CleanupExpiredPriorities();
|
||||
|
||||
// Check and release horizons for all robots
|
||||
await _baseHorizonManagementService.CheckAndReleaseHorizonsAsync(cancellationToken: stoppingToken);
|
||||
|
||||
// Real-time conflict detection and resolution loop
|
||||
await ProcessConflictsRealTimeAsync(stoppingToken);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Error($"Error in conflict detection loop: {ex.Message}");
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
_logger.Info("TrafficControlService stopping");
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Event Handlers
|
||||
|
||||
/// <summary>
|
||||
/// Handle State message received event - check if robot is near end of Base
|
||||
/// </summary>
|
||||
private void OnStateMessageReceived(object? sender, StateMessageReceivedEvent e)
|
||||
{
|
||||
try
|
||||
{
|
||||
var robotId = e.RobotId;
|
||||
var stateMsg = e.StateMessage;
|
||||
|
||||
// Get robot route
|
||||
var route = _routeStorageService.GetRobotRouteAsync(robotId).GetAwaiter().GetResult();
|
||||
if (route == null)
|
||||
{
|
||||
return; // No active route, nothing to check
|
||||
}
|
||||
|
||||
// Check if robot is near end of Base (1-2 segments remaining)
|
||||
var remainingBaseSegments = _baseHorizonManagementService.CountRemainingBaseSegments(route, stateMsg);
|
||||
if (remainingBaseSegments <= 2 && route.Horizon.Count > 0)
|
||||
{
|
||||
// Trigger horizon release check (async, fire and forget)
|
||||
_ = Task.Run(async () =>
|
||||
{
|
||||
try
|
||||
{
|
||||
await _baseHorizonManagementService.CheckAndReleaseHorizonsAsync(robotId, CancellationToken.None);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Error($"Error checking horizon release for robot {robotId} after state update: {ex.Message}");
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Error($"Error handling state message for robot {e.RobotId}: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Private Helpers
|
||||
|
||||
/// <summary>
|
||||
/// Process conflicts in real-time: detect, evaluate, and resolve
|
||||
/// </summary>
|
||||
private async Task ProcessConflictsRealTimeAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
// 1. Detect all conflicts
|
||||
var conflicts = await _conflictDetectionService.DetectConflictsAsync(cancellationToken);
|
||||
if (conflicts == null || conflicts.Count == 0)
|
||||
{
|
||||
return; // No conflicts detected
|
||||
}
|
||||
|
||||
_logger.Debug($"Detected {conflicts.Count} conflict(s) in real-time loop");
|
||||
|
||||
// 2. Evaluate conflicts for resolution optimization
|
||||
var evaluatedConflicts = await _conflictResolutionService.EvaluateConflictsForResolutionAsync(conflicts, cancellationToken);
|
||||
if (evaluatedConflicts == null || evaluatedConflicts.Count == 0)
|
||||
{
|
||||
return; // No conflicts to resolve
|
||||
}
|
||||
|
||||
// 3. Resolve conflicts in priority order
|
||||
var resolvedCount = 0;
|
||||
var failedCount = 0;
|
||||
var skippedCount = 0;
|
||||
|
||||
foreach (var conflict in evaluatedConflicts)
|
||||
{
|
||||
try
|
||||
{
|
||||
var resolved = await _conflictResolutionService.ResolveConflictAsync(conflict, cancellationToken);
|
||||
var robotIds = string.Join(", ", conflict.InvolvedRobots);
|
||||
if (resolved)
|
||||
{
|
||||
resolvedCount++;
|
||||
_logger.Info($"Resolved conflict between {robotIds} (Type: {conflict.Type})");
|
||||
}
|
||||
else
|
||||
{
|
||||
failedCount++;
|
||||
_logger.Warning($"Failed to resolve conflict between {robotIds} (Type: {conflict.Type})");
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
failedCount++;
|
||||
var robotIds = string.Join(", ", conflict.InvolvedRobots);
|
||||
_logger.Error($"Error resolving conflict between {robotIds}: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
if (resolvedCount > 0 || failedCount > 0)
|
||||
{
|
||||
_logger.Info($"Conflict resolution summary: {resolvedCount} resolved, {failedCount} failed, {skippedCount} skipped");
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Error($"Error in ProcessConflictsRealTimeAsync: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
#endregion
|
||||
}
|
||||
Reference in New Issue
Block a user