Initial commit

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

View File

@@ -0,0 +1,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;
}

View File

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

View File

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

View File

@@ -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";
}

View File

@@ -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,
}

View File

@@ -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) { }
}

View File

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

View File

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

View File

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

View File

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