Files
FlyShotHost/src/Flyshot.Core.Config/RobotConfigLoader.cs
yunxiao.zhu a6579f1e5b feat(*): 添加 ConfigRoot 运行时配置目录隔离
* 新增 ControllerClientCompatOptions.ConfigRoot 及解析方法
* 兼容层默认从运行目录 Config 加载模型、轨迹和配置
* 移除隐式父工作区根目录推断,旧路径仅在显式配置时生效
* Host 项目编译时将 Config 目录复制到输出目录
* 请求响应日志中间件忽略 /api/status/snapshot 高频轮询
* 补充 ConfigRoot 和日志过滤相关单元测试
2026-04-29 18:27:03 +08:00

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