* 新增 ControllerClientCompatOptions.ConfigRoot 及解析方法 * 兼容层默认从运行目录 Config 加载模型、轨迹和配置 * 移除隐式父工作区根目录推断,旧路径仅在显式配置时生效 * Host 项目编译时将 Config 目录复制到输出目录 * 请求响应日志中间件忽略 /api/status/snapshot 高频轮询 * 补充 ConfigRoot 和日志过滤相关单元测试
279 lines
9.6 KiB
C#
279 lines
9.6 KiB
C#
using System.Text.Json;
|
|
using Flyshot.Core.Domain;
|
|
|
|
namespace Flyshot.Core.Config;
|
|
|
|
/// <summary>
|
|
/// 表示从旧版 RobotConfig.json 规范化后的机器人运行参数。
|
|
/// </summary>
|
|
public sealed class CompatibilityRobotSettings
|
|
{
|
|
/// <summary>
|
|
/// 初始化一份经过规范化的机器人兼容设置。
|
|
/// </summary>
|
|
public CompatibilityRobotSettings(
|
|
bool useDo,
|
|
IEnumerable<int> ioAddresses,
|
|
int ioKeepCycles,
|
|
double accLimitScale,
|
|
double jerkLimitScale,
|
|
int adaptIcspTryNum)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(ioAddresses);
|
|
|
|
if (ioKeepCycles < 0)
|
|
{
|
|
throw new ArgumentOutOfRangeException(nameof(ioKeepCycles), "IO 保持周期不能为负数。");
|
|
}
|
|
|
|
if (accLimitScale <= 0.0)
|
|
{
|
|
throw new ArgumentOutOfRangeException(nameof(accLimitScale), "加速度倍率必须大于 0。");
|
|
}
|
|
|
|
if (jerkLimitScale <= 0.0)
|
|
{
|
|
throw new ArgumentOutOfRangeException(nameof(jerkLimitScale), "Jerk 倍率必须大于 0。");
|
|
}
|
|
|
|
if (adaptIcspTryNum < 0)
|
|
{
|
|
throw new ArgumentOutOfRangeException(nameof(adaptIcspTryNum), "补点尝试次数不能为负数。");
|
|
}
|
|
|
|
// 固化地址数组,避免上层代码在加载后原地篡改配置内容。
|
|
var copiedIoAddresses = ioAddresses.ToArray();
|
|
if (copiedIoAddresses.Any(address => address < 0))
|
|
{
|
|
throw new ArgumentException("IO 地址不能为负数。", nameof(ioAddresses));
|
|
}
|
|
|
|
UseDo = useDo;
|
|
IoAddresses = copiedIoAddresses;
|
|
IoKeepCycles = ioKeepCycles;
|
|
AccLimitScale = accLimitScale;
|
|
JerkLimitScale = jerkLimitScale;
|
|
AdaptIcspTryNum = adaptIcspTryNum;
|
|
}
|
|
|
|
/// <summary>
|
|
/// 获取是否启用伺服同步 IO。
|
|
/// </summary>
|
|
public bool UseDo { get; }
|
|
|
|
/// <summary>
|
|
/// 获取默认 IO 地址组。
|
|
/// </summary>
|
|
public IReadOnlyList<int> IoAddresses { get; }
|
|
|
|
/// <summary>
|
|
/// 获取单次触发保持的伺服周期数。
|
|
/// </summary>
|
|
public int IoKeepCycles { get; }
|
|
|
|
/// <summary>
|
|
/// 获取加速度全局倍率。
|
|
/// </summary>
|
|
public double AccLimitScale { get; }
|
|
|
|
/// <summary>
|
|
/// 获取 Jerk 全局倍率。
|
|
/// </summary>
|
|
public double JerkLimitScale { get; }
|
|
|
|
/// <summary>
|
|
/// 获取自适应补点最大尝试次数。
|
|
/// </summary>
|
|
public int AdaptIcspTryNum { get; }
|
|
}
|
|
|
|
/// <summary>
|
|
/// 表示一次旧版 RobotConfig.json 的完整加载结果。
|
|
/// </summary>
|
|
public sealed class LoadedRobotConfig
|
|
{
|
|
/// <summary>
|
|
/// 初始化一份规范化后的旧配置文档。
|
|
/// </summary>
|
|
public LoadedRobotConfig(
|
|
string sourcePath,
|
|
CompatibilityRobotSettings robot,
|
|
IReadOnlyDictionary<string, FlyshotProgram> programs)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(sourcePath))
|
|
{
|
|
throw new ArgumentException("配置源路径不能为空。", nameof(sourcePath));
|
|
}
|
|
|
|
SourcePath = sourcePath;
|
|
Robot = robot ?? throw new ArgumentNullException(nameof(robot));
|
|
Programs = programs ?? throw new ArgumentNullException(nameof(programs));
|
|
}
|
|
|
|
/// <summary>
|
|
/// 获取实际命中的配置文件路径。
|
|
/// </summary>
|
|
public string SourcePath { get; }
|
|
|
|
/// <summary>
|
|
/// 获取机器人级兼容设置。
|
|
/// </summary>
|
|
public CompatibilityRobotSettings Robot { get; }
|
|
|
|
/// <summary>
|
|
/// 获取按名称索引的飞拍程序集合。
|
|
/// </summary>
|
|
public IReadOnlyDictionary<string, FlyshotProgram> Programs { get; }
|
|
}
|
|
|
|
/// <summary>
|
|
/// 负责读取旧版 RobotConfig.json 并转换成领域层可直接消费的结构。
|
|
/// </summary>
|
|
public sealed class RobotConfigLoader
|
|
{
|
|
/// <summary>
|
|
/// 加载一份旧版 RobotConfig.json。
|
|
/// </summary>
|
|
/// <param name="configPath">调用方传入的配置路径。</param>
|
|
/// <param name="repoRoot">用于兼容搜索的仓库根目录;为空时按当前工作目录推断。</param>
|
|
/// <returns>规范化后的配置文档。</returns>
|
|
public LoadedRobotConfig Load(string configPath, string? repoRoot = null)
|
|
{
|
|
var resolvedRepoRoot = ResolveRepoRoot(repoRoot);
|
|
var resolvedConfigPath = PathCompatibility.ResolveConfigPath(configPath, resolvedRepoRoot);
|
|
|
|
using var document = JsonDocument.Parse(File.ReadAllText(resolvedConfigPath));
|
|
var root = document.RootElement;
|
|
var robotElement = root.GetProperty("robot");
|
|
var flyingShotsElement = root.GetProperty("flying_shots");
|
|
|
|
var robot = new CompatibilityRobotSettings(
|
|
useDo: ReadBoolean(robotElement, "use_do", defaultValue: false),
|
|
ioAddresses: ReadIntArray(robotElement, "io_addr"),
|
|
ioKeepCycles: ReadInt(robotElement, "io_keep_cycles", defaultValue: 0),
|
|
accLimitScale: ReadDouble(robotElement, "acc_limit", defaultValue: 1.0),
|
|
jerkLimitScale: ReadDouble(robotElement, "jerk_limit", defaultValue: 1.0),
|
|
adaptIcspTryNum: ReadInt(robotElement, "adapt_icsp_try_num", defaultValue: 0));
|
|
|
|
var programs = new Dictionary<string, FlyshotProgram>(StringComparer.Ordinal);
|
|
foreach (var programElement in flyingShotsElement.EnumerateObject())
|
|
{
|
|
var programName = programElement.Name;
|
|
var program = ParseProgram(programName, programElement.Value);
|
|
programs.Add(programName, program);
|
|
}
|
|
|
|
return new LoadedRobotConfig(
|
|
sourcePath: resolvedConfigPath,
|
|
robot: robot,
|
|
programs: programs);
|
|
}
|
|
|
|
/// <summary>
|
|
/// 把单个 flying_shots 节点转成领域层 FlyshotProgram。
|
|
/// </summary>
|
|
private static FlyshotProgram ParseProgram(string programName, JsonElement programElement)
|
|
{
|
|
var waypointRows = programElement.GetProperty("traj_waypoints").EnumerateArray().ToArray();
|
|
var shotFlagElements = programElement.GetProperty("shot_flags").EnumerateArray().ToArray();
|
|
|
|
var waypoints = waypointRows
|
|
.Select(row => new JointWaypoint(row.EnumerateArray().Select(static value => value.GetDouble())))
|
|
.ToArray();
|
|
|
|
var shotFlags = shotFlagElements
|
|
.Select(static value => value.ValueKind == JsonValueKind.True || (value.ValueKind == JsonValueKind.Number && value.GetInt32() != 0))
|
|
.ToArray();
|
|
|
|
var waypointCount = waypoints.Length;
|
|
var offsetValues = programElement.TryGetProperty("offset_values", out var offsetValuesElement)
|
|
? offsetValuesElement.EnumerateArray().Select(static value => value.GetInt32()).ToArray()
|
|
: Enumerable.Repeat(0, waypointCount).ToArray();
|
|
|
|
var addressGroups = programElement.TryGetProperty("addr", out var addressElement)
|
|
? addressElement
|
|
.EnumerateArray()
|
|
.Select(group => new IoAddressGroup(group.EnumerateArray().Select(static value => value.GetInt32())))
|
|
.ToArray()
|
|
: Enumerable.Range(0, waypointCount)
|
|
.Select(_ => new IoAddressGroup(Array.Empty<int>()))
|
|
.ToArray();
|
|
|
|
return new FlyshotProgram(
|
|
name: programName,
|
|
waypoints: waypoints,
|
|
shotFlags: shotFlags,
|
|
offsetValues: offsetValues,
|
|
addressGroups: addressGroups);
|
|
}
|
|
|
|
/// <summary>
|
|
/// 解析布尔或 0/1 风格的兼容字段。
|
|
/// </summary>
|
|
private static bool ReadBoolean(JsonElement parent, string propertyName, bool defaultValue)
|
|
{
|
|
if (!parent.TryGetProperty(propertyName, out var property))
|
|
{
|
|
return defaultValue;
|
|
}
|
|
|
|
return property.ValueKind switch
|
|
{
|
|
JsonValueKind.True => true,
|
|
JsonValueKind.False => false,
|
|
JsonValueKind.Number => property.GetInt32() != 0,
|
|
_ => defaultValue
|
|
};
|
|
}
|
|
|
|
/// <summary>
|
|
/// 读取整数数组;字段不存在时返回空数组。
|
|
/// </summary>
|
|
private static IReadOnlyList<int> ReadIntArray(JsonElement parent, string propertyName)
|
|
{
|
|
return parent.TryGetProperty(propertyName, out var property)
|
|
? property.EnumerateArray().Select(static value => value.GetInt32()).ToArray()
|
|
: Array.Empty<int>();
|
|
}
|
|
|
|
/// <summary>
|
|
/// 读取整数兼容字段。
|
|
/// </summary>
|
|
private static int ReadInt(JsonElement parent, string propertyName, int defaultValue)
|
|
{
|
|
return parent.TryGetProperty(propertyName, out var property) ? property.GetInt32() : defaultValue;
|
|
}
|
|
|
|
/// <summary>
|
|
/// 读取浮点兼容字段。
|
|
/// </summary>
|
|
private static double ReadDouble(JsonElement parent, string propertyName, double defaultValue)
|
|
{
|
|
return parent.TryGetProperty(propertyName, out var property) ? property.GetDouble() : defaultValue;
|
|
}
|
|
|
|
/// <summary>
|
|
/// 推断当前 replacement 仓库根目录,优先使用调用方显式传入的值。
|
|
/// </summary>
|
|
private static string ResolveRepoRoot(string? repoRoot)
|
|
{
|
|
if (!string.IsNullOrWhiteSpace(repoRoot))
|
|
{
|
|
return Path.GetFullPath(repoRoot);
|
|
}
|
|
|
|
var current = new DirectoryInfo(Directory.GetCurrentDirectory());
|
|
while (current is not null)
|
|
{
|
|
if (File.Exists(Path.Combine(current.FullName, "FlyshotReplacement.sln")))
|
|
{
|
|
return current.FullName;
|
|
}
|
|
|
|
current = current.Parent;
|
|
}
|
|
|
|
return Directory.GetCurrentDirectory();
|
|
}
|
|
}
|