✨ feat: 初始化飞拍替换方案仓库骨架
* 建立 .NET 8 解决方案及分层项目结构 * 添加 Flyshot.Core.Domain 领域模型(机器人、轨迹、运动学) * 添加 Flyshot.Core.Planning 规划层(ICSP、CubicSpline、采样器) * 添加 Flyshot.Core.Triggering 触发时序与 IO 时间轴 * 添加 Flyshot.Core.Config 配置兼容与 .robot 解析 * 添加 Flyshot.Server.Host 最小宿主及 /healthz 端点 * 补充单元测试与集成测试项目 * 添加 CLAUDE.md、AGENTS.md、README.md 项目规范
This commit is contained in:
278
src/Flyshot.Core.Config/RobotConfigLoader.cs
Normal file
278
src/Flyshot.Core.Config/RobotConfigLoader.cs
Normal file
@@ -0,0 +1,278 @@
|
||||
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>
|
||||
/// 推断仓库根目录,优先使用调用方显式传入的值。
|
||||
/// </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 Path.GetFullPath(Path.Combine(current.FullName, ".."));
|
||||
}
|
||||
|
||||
current = current.Parent;
|
||||
}
|
||||
|
||||
return Directory.GetCurrentDirectory();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user