Files
FlyShotHost/src/Flyshot.ControllerClientCompat/FlyshotTrajectoryStore.cs
yunxiao.zhu 5193f537b0 ✨ feat(planning): 添加离线飞拍规划能力
* 固化离线飞拍轨迹加载、保存和规划结果。

* 真机初始化失败时保留模型加载后的离线规划能力。

* 更新状态接口、调试页和回归测试覆盖。
2026-06-24 11:40:07 +08:00

262 lines
11 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

using System.Text.Json;
using System.Text.Json.Nodes;
using Flyshot.Core.Config;
using Flyshot.Core.Domain;
using Microsoft.Extensions.Logging;
namespace Flyshot.ControllerClientCompat;
/// <summary>
/// 使用运行目录 Config/RobotConfig.json 持久化单机器人飞拍轨迹和机器人配置。
/// </summary>
public sealed class JsonFlyshotTrajectoryStore
{
private readonly ControllerClientCompatOptions _options;
private readonly RobotConfigLoader _configLoader;
private readonly ILogger<JsonFlyshotTrajectoryStore>? _logger;
/// <summary>
/// 初始化基于 JSON 文件的轨迹存储。
/// </summary>
/// <param name="options">兼容层基础配置,用于定位运行配置根目录。</param>
/// <param name="configLoader">旧版 RobotConfig.json 加载器,用于反序列化已保存的轨迹。</param>
/// <param name="logger">日志记录器;允许 null。</param>
public JsonFlyshotTrajectoryStore(ControllerClientCompatOptions options, RobotConfigLoader configLoader, ILogger<JsonFlyshotTrajectoryStore>? logger = null)
{
_options = options ?? throw new ArgumentNullException(nameof(options));
_configLoader = configLoader ?? throw new ArgumentNullException(nameof(configLoader));
_logger = logger;
}
/// <summary>
/// 将单条轨迹持久化到统一 RobotConfig.json,同时更新机器人配置段。
/// </summary>
/// <param name="robotName">当前已初始化的机器人名称,仅用于日志诊断。</param>
/// <param name="settings">当前机器人级兼容配置。</param>
/// <param name="trajectory">要保存的已上传轨迹。</param>
public void Save(CompatibilityRobotSettings settings, ControllerClientCompatUploadedTrajectory trajectory)
{
ArgumentNullException.ThrowIfNull(settings);
ArgumentNullException.ThrowIfNull(trajectory);
_logger?.LogInformation(
"RobotConfig 保存轨迹: robot={RobotName}, name={TrajectoryName}, waypoints={WaypointCount}",
"fixed-config",
trajectory.Name,
trajectory.Waypoints.Count);
var path = ResolveStorePath();
var directory = Path.GetDirectoryName(path)!;
Directory.CreateDirectory(directory);
JsonObject root;
if (File.Exists(path))
{
var existingJson = File.ReadAllText(path);
root = JsonNode.Parse(existingJson)?.AsObject() ?? new JsonObject();
}
else
{
root = new JsonObject();
}
// 更新 robot 配置段,保持与旧版 RobotConfig.json 字段名一致。
root["robot"] = SerializeRobotSettings(settings);
// 确保 flying_shots 节点存在。
if (!root.TryGetPropertyValue("flying_shots", out var flyingShotsNode) || flyingShotsNode is not JsonObject flyingShotsObj)
{
flyingShotsObj = new JsonObject();
root["flying_shots"] = flyingShotsObj;
}
flyingShotsObj[trajectory.Name] = SerializeTrajectory(trajectory);
var writeOptions = new JsonSerializerOptions
{
WriteIndented = true,
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower
};
File.WriteAllText(path, root.ToJsonString(writeOptions));
_logger?.LogInformation("RobotConfig 轨迹已保存到 {Path}", path);
}
/// <summary>
/// 兼容旧调用签名;机器人名称不再参与固定配置文件分区。
/// </summary>
/// <param name="robotName">仅用于兼容的机器人名称。</param>
/// <param name="settings">机器人级兼容配置。</param>
/// <param name="trajectory">待保存轨迹。</param>
public void Save(string robotName, CompatibilityRobotSettings settings, ControllerClientCompatUploadedTrajectory trajectory)
{
Save(settings, trajectory);
}
/// <summary>
/// 从统一 RobotConfig.json 删除指定名称的轨迹。
/// </summary>
/// <param name="robotName">当前已初始化的机器人名称,仅用于日志诊断。</param>
/// <param name="trajectoryName">要删除的轨迹名称。</param>
public void Delete(string trajectoryName)
{
if (string.IsNullOrWhiteSpace(trajectoryName))
{
throw new ArgumentException("轨迹名称不能为空。", nameof(trajectoryName));
}
_logger?.LogInformation("RobotConfig 删除轨迹: name={TrajectoryName}", trajectoryName);
var path = ResolveStorePath();
if (!File.Exists(path))
{
_logger?.LogWarning("RobotConfig 删除失败: 文件不存在 {Path}", path);
return;
}
var existingJson = File.ReadAllText(path);
var root = JsonNode.Parse(existingJson)?.AsObject();
if (root is null)
{
_logger?.LogWarning("RobotConfig 删除失败: 无法解析 JSON {Path}", path);
return;
}
if (root.TryGetPropertyValue("flying_shots", out var flyingShotsNode) && flyingShotsNode is JsonObject flyingShotsObj)
{
var removed = flyingShotsObj.Remove(trajectoryName);
if (removed)
{
var writeOptions = new JsonSerializerOptions
{
WriteIndented = true,
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower
};
File.WriteAllText(path, root.ToJsonString(writeOptions));
_logger?.LogInformation("RobotConfig 轨迹已删除: {TrajectoryName}", trajectoryName);
}
else
{
_logger?.LogWarning("RobotConfig 删除失败: 轨迹不存在 {TrajectoryName}", trajectoryName);
}
}
}
/// <summary>
/// 兼容旧调用签名;机器人名称不再参与固定配置文件分区。
/// </summary>
/// <param name="robotName">仅用于兼容的机器人名称。</param>
/// <param name="trajectoryName">待删除轨迹名称。</param>
public void Delete(string robotName, string trajectoryName)
{
Delete(trajectoryName);
}
/// <summary>
/// 加载统一 RobotConfig.json 中的所有轨迹,并回传机器人配置。
/// </summary>
/// <param name="robotName">当前已初始化的机器人名称,仅用于日志诊断。</param>
/// <param name="settings">输出 RobotConfig.json 中的机器人配置;若文件不存在或解析失败则为 null。</param>
/// <returns>按轨迹名称索引的已上传轨迹集合。</returns>
public IReadOnlyDictionary<string, ControllerClientCompatUploadedTrajectory> LoadAll(out CompatibilityRobotSettings? settings)
{
var path = ResolveStorePath();
if (!File.Exists(path))
{
_logger?.LogInformation("RobotConfig 无持久化数据: {Path}", path);
settings = null;
return new Dictionary<string, ControllerClientCompatUploadedTrajectory>(StringComparer.Ordinal);
}
try
{
_logger?.LogInformation("RobotConfig 正在加载: {Path}", path);
var loaded = _configLoader.Load(path, _options.ResolveConfigRoot());
settings = loaded.Robot;
var dict = new Dictionary<string, ControllerClientCompatUploadedTrajectory>(StringComparer.Ordinal);
foreach (var program in loaded.Programs)
{
var traj = new ControllerClientCompatUploadedTrajectory(
name: program.Value.Name,
waypoints: program.Value.Waypoints.Select(static wp => wp.Positions),
shotFlags: program.Value.ShotFlags,
offsetValues: program.Value.OffsetValues,
addressGroups: program.Value.AddressGroups.Select(static g => g.Addresses));
dict[program.Key] = traj;
}
_logger?.LogInformation(
"RobotConfig 加载完成: robot={RobotName}, 轨迹数={Count}, useDo={UseDo}, ioKeepCycles={IoKeepCycles}",
"fixed-config",
dict.Count,
settings?.UseDo,
settings?.IoKeepCycles);
return dict;
}
catch (Exception ex)
{
_logger?.LogError(ex, "RobotConfig 加载失败: {Path}", path);
settings = null;
return new Dictionary<string, ControllerClientCompatUploadedTrajectory>(StringComparer.Ordinal);
}
}
/// <summary>
/// 兼容旧调用签名;机器人名称不再参与固定配置文件分区。
/// </summary>
/// <param name="robotName">仅用于兼容的机器人名称。</param>
/// <param name="settings">输出机器人配置。</param>
/// <returns>固定 RobotConfig.json 中的全部飞拍资产。</returns>
public IReadOnlyDictionary<string, ControllerClientCompatUploadedTrajectory> LoadAll(
string robotName,
out CompatibilityRobotSettings? settings)
{
return LoadAll(out settings);
}
/// <summary>
/// 把机器人兼容配置序列化为 JSON 对象,字段名与旧版 RobotConfig.json 一致。
/// </summary>
private static JsonObject SerializeRobotSettings(CompatibilityRobotSettings settings)
{
return new JsonObject
{
["use_do"] = JsonValue.Create(settings.UseDo),
["io_addr"] = JsonSerializer.SerializeToNode(settings.IoAddresses),
["io_keep_cycles"] = JsonValue.Create(settings.IoKeepCycles),
["trigger_sample_index_offset_cycles"] = JsonValue.Create(settings.TriggerSampleIndexOffsetCycles),
["acc_limit"] = JsonValue.Create(settings.AccLimitScale),
["jerk_limit"] = JsonValue.Create(settings.JerkLimitScale),
["adapt_icsp_try_num"] = JsonValue.Create(settings.AdaptIcspTryNum),
["planning_speed_scale"] = JsonValue.Create(settings.PlanningSpeedScale),
["speed_ratio"] = JsonValue.Create(settings.SpeedRatio),
["smooth_start_stop_timing"] = JsonValue.Create(settings.SmoothStartStopTiming)
};
}
/// <summary>
/// 把已上传轨迹序列化为 JSON 对象,字段名与旧版 RobotConfig.json 的 flying_shots 节点一致。
/// </summary>
private static JsonObject SerializeTrajectory(ControllerClientCompatUploadedTrajectory trajectory)
{
return new JsonObject
{
["traj_waypoints"] = JsonSerializer.SerializeToNode(trajectory.Waypoints),
["shot_flags"] = JsonSerializer.SerializeToNode(trajectory.ShotFlags),
["offset_values"] = JsonSerializer.SerializeToNode(trajectory.OffsetValues),
["addr"] = JsonSerializer.SerializeToNode(trajectory.AddressGroups)
};
}
/// <summary>
/// 解析单程序单机器人的统一配置文件路径。
/// </summary>
private string ResolveStorePath()
{
return Path.Combine(_options.ResolveConfigRoot(), "RobotConfig.json");
}
}