新增 OTA WatchDog 编排与车队健康/报警后端。
覆盖包库回传、任务下发、CDM 任务同步与报警采集,并为包/任务 ID 与上传文件名加上路径安全校验。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,26 @@
|
||||
using System.Security.Cryptography;
|
||||
|
||||
namespace MiGu.Server.Ota;
|
||||
|
||||
public static class OtaHash
|
||||
{
|
||||
/// <summary>与参考 OTA 工具一致:MD5 → Base64,并去掉 '-'。</summary>
|
||||
public static string OfFile(string path)
|
||||
{
|
||||
using var fs = File.OpenRead(path);
|
||||
var hash = MD5.HashData(fs);
|
||||
return Convert.ToBase64String(hash).Replace("-", "", StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
public static string OfBytes(ReadOnlySpan<byte> bytes)
|
||||
{
|
||||
var hash = MD5.HashData(bytes);
|
||||
return Convert.ToBase64String(hash).Replace("-", "", StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
public static string Short(string? hash, int len = 8)
|
||||
{
|
||||
if (string.IsNullOrEmpty(hash)) return "—";
|
||||
return hash.Length <= len ? hash : hash[..len];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,433 @@
|
||||
using System.Collections.Concurrent;
|
||||
|
||||
namespace MiGu.Server.Ota;
|
||||
|
||||
public sealed class OtaJobRunner
|
||||
{
|
||||
private readonly OtaStore _store;
|
||||
private readonly WatchDogClient _wd;
|
||||
private readonly OtaVehicleSource _vehicles;
|
||||
private readonly ILogger<OtaJobRunner> _log;
|
||||
private readonly ConcurrentDictionary<string, CancellationTokenSource> _running = new();
|
||||
|
||||
public OtaJobRunner(OtaStore store, WatchDogClient wd, OtaVehicleSource vehicles, ILogger<OtaJobRunner> log)
|
||||
{
|
||||
_store = store;
|
||||
_wd = wd;
|
||||
_vehicles = vehicles;
|
||||
_log = log;
|
||||
}
|
||||
|
||||
public OtaJob EnqueueSync(CreateSyncJobRequest req, string? user)
|
||||
{
|
||||
var target = _store.GetTarget() ?? throw new InvalidOperationException("未设置目标版本,请先在版本库激活");
|
||||
var components = (req.Components is { Count: > 0 } ? req.Components : OtaPathMap.ComponentKeys.ToList())
|
||||
.Where(c => target.Components.ContainsKey(c))
|
||||
.ToList();
|
||||
if (components.Count == 0) throw new InvalidOperationException("目标版本中无选定组件");
|
||||
if (req.CarIds.Count == 0) throw new InvalidOperationException("请选择车辆");
|
||||
|
||||
var settings = _store.GetSettings();
|
||||
var job = new OtaJob
|
||||
{
|
||||
Id = _store.NextJobId(),
|
||||
Kind = "sync",
|
||||
Status = "pending",
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
CreatedBy = user,
|
||||
PackageId = target.PackageId,
|
||||
CarIds = req.CarIds.ToList(),
|
||||
Components = components,
|
||||
RequireLatencyCheck = req.RequireLatencyCheck ?? settings.LatencyEnabled
|
||||
};
|
||||
foreach (var carId in job.CarIds)
|
||||
foreach (var comp in components)
|
||||
job.Steps.Add(new OtaJobStep { CarId = carId, Component = comp, Status = "pending" });
|
||||
job.TotalSteps = job.Steps.Count;
|
||||
_store.SaveJob(job);
|
||||
_ = Task.Run(() => RunAsync(job.Id));
|
||||
return job;
|
||||
}
|
||||
|
||||
public OtaJob EnqueueCustomFile(CreateCustomFileJobRequest req, IReadOnlyList<OtaCustomFileItem> files, string? user)
|
||||
{
|
||||
if (req.CarIds.Count == 0) throw new InvalidOperationException("请选择车辆");
|
||||
if (files.Count == 0) throw new InvalidOperationException("请至少添加一个本地文件");
|
||||
if (string.IsNullOrWhiteSpace(req.RemotePath)) throw new InvalidOperationException("请填写小车内目标路径");
|
||||
var settings = _store.GetSettings();
|
||||
var ops = req.RestartOps is { Count: > 0 }
|
||||
? req.RestartOps.Distinct().ToList()
|
||||
: new List<int> { req.RestartOp };
|
||||
if (ops.Count == 0) ops.Add(-1);
|
||||
|
||||
var job = new OtaJob
|
||||
{
|
||||
Id = _store.NextJobId(),
|
||||
Kind = "customFile",
|
||||
Status = "pending",
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
CreatedBy = user,
|
||||
CarIds = req.CarIds.ToList(),
|
||||
CustomFileName = files[0].FileName,
|
||||
CustomRemotePath = req.RemotePath,
|
||||
CustomRestartOp = ops[0],
|
||||
CustomLocalPath = files[0].LocalPath,
|
||||
CustomFiles = files.ToList(),
|
||||
CustomRestartOps = ops,
|
||||
RequireLatencyCheck = req.RequireLatencyCheck ?? settings.LatencyEnabled,
|
||||
Components = new List<string> { "custom" }
|
||||
};
|
||||
foreach (var carId in job.CarIds)
|
||||
job.Steps.Add(new OtaJobStep { CarId = carId, Component = $"custom×{files.Count}", Status = "pending" });
|
||||
job.TotalSteps = job.Steps.Count;
|
||||
_store.SaveJob(job);
|
||||
_ = Task.Run(() => RunAsync(job.Id));
|
||||
return job;
|
||||
}
|
||||
|
||||
public OtaJob EnqueueConfigPush(CreateConfigPushRequest req, string? user)
|
||||
{
|
||||
if (req.CarIds.Count == 0) throw new InvalidOperationException("请选择车辆");
|
||||
var app = req.App.Trim().ToLowerInvariant();
|
||||
if (app is not ("medulla" or "detour" or "clumsy"))
|
||||
throw new InvalidOperationException("app 须为 medulla|detour|clumsy");
|
||||
var settings = _store.GetSettings();
|
||||
var job = new OtaJob
|
||||
{
|
||||
Id = _store.NextJobId(),
|
||||
Kind = "configPush",
|
||||
Status = "pending",
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
CreatedBy = user,
|
||||
CarIds = req.CarIds.ToList(),
|
||||
ConfigApp = app,
|
||||
ConfigJson = req.Json,
|
||||
RequireLatencyCheck = req.RequireLatencyCheck ?? settings.LatencyEnabled,
|
||||
Components = new List<string> { $"config:{app}" }
|
||||
};
|
||||
foreach (var carId in job.CarIds)
|
||||
job.Steps.Add(new OtaJobStep { CarId = carId, Component = $"config:{app}", Status = "pending" });
|
||||
job.TotalSteps = job.Steps.Count;
|
||||
_store.SaveJob(job);
|
||||
_ = Task.Run(() => RunAsync(job.Id));
|
||||
return job;
|
||||
}
|
||||
|
||||
public bool Cancel(string jobId)
|
||||
{
|
||||
var job = _store.GetJob(jobId);
|
||||
if (job == null) return false;
|
||||
if (job.Status is "succeeded" or "failed" or "partial" or "cancelled") return false;
|
||||
// 正在运行:只发取消信号,由 RunAsync 统一收尾,避免与运行线程并发写同一 job 文件。
|
||||
if (_running.TryGetValue(jobId, out var cts))
|
||||
{
|
||||
cts.Cancel();
|
||||
return true;
|
||||
}
|
||||
// 尚未开始(Task.Run 排队中):直接落盘取消;RunAsync 启动时有 status 守卫会跳过执行。
|
||||
foreach (var step in job.Steps.Where(s => s.Status == "pending"))
|
||||
{
|
||||
step.Status = "skipped";
|
||||
step.Error = "已取消";
|
||||
}
|
||||
job.Status = "cancelled";
|
||||
job.FinishedAt = DateTimeOffset.UtcNow;
|
||||
job.DoneSteps = job.Steps.Count(s => s.Status is "succeeded" or "failed" or "skipped");
|
||||
_store.SaveJob(job);
|
||||
return true;
|
||||
}
|
||||
|
||||
public OtaJob? RetryFailed(string jobId, string? user)
|
||||
{
|
||||
var old = _store.GetJob(jobId);
|
||||
if (old == null) return null;
|
||||
var failedCars = old.Steps.Where(s => s.Status == "failed").Select(s => s.CarId).Distinct().ToList();
|
||||
if (failedCars.Count == 0) throw new InvalidOperationException("没有失败项可重试");
|
||||
|
||||
return old.Kind switch
|
||||
{
|
||||
"sync" => EnqueueSync(new CreateSyncJobRequest
|
||||
{
|
||||
CarIds = failedCars,
|
||||
Components = old.Components,
|
||||
RequireLatencyCheck = old.RequireLatencyCheck
|
||||
}, user),
|
||||
"customFile" when ResolveCustomFiles(old).Count > 0 =>
|
||||
EnqueueCustomFile(new CreateCustomFileJobRequest
|
||||
{
|
||||
CarIds = failedCars,
|
||||
RemotePath = old.CustomRemotePath ?? "",
|
||||
RestartOps = old.CustomRestartOps is { Count: > 0 }
|
||||
? old.CustomRestartOps
|
||||
: new List<int> { old.CustomRestartOp ?? -1 },
|
||||
RequireLatencyCheck = old.RequireLatencyCheck
|
||||
}, ResolveCustomFiles(old), user),
|
||||
"configPush" => EnqueueConfigPush(new CreateConfigPushRequest
|
||||
{
|
||||
CarIds = failedCars,
|
||||
App = old.ConfigApp ?? "",
|
||||
Json = old.ConfigJson ?? "",
|
||||
RequireLatencyCheck = old.RequireLatencyCheck
|
||||
}, user),
|
||||
_ => throw new InvalidOperationException("无法重试该任务类型或文件已丢失")
|
||||
};
|
||||
}
|
||||
|
||||
private async Task RunAsync(string jobId)
|
||||
{
|
||||
var cts = new CancellationTokenSource();
|
||||
if (!_running.TryAdd(jobId, cts)) return;
|
||||
// 单一内存实例贯穿全程;所有「改状态 + 落盘」都在 jobLock 下串行,杜绝并发车批次的丢更新。
|
||||
var jobLock = new object();
|
||||
try
|
||||
{
|
||||
var job = _store.GetJob(jobId);
|
||||
if (job == null) return;
|
||||
// 守卫:排队期间被取消 / 已终结的任务不再执行。
|
||||
if (job.Status is "cancelled" or "succeeded" or "failed" or "partial") return;
|
||||
var settings = _store.GetSettings();
|
||||
var cars = await _vehicles.ListCarsAsync(cts.Token);
|
||||
var byId = cars.ToDictionary(c => c.Id, StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
job.Status = job.RequireLatencyCheck ? "probing" : "running";
|
||||
_store.SaveJob(job);
|
||||
|
||||
// 解析 IP
|
||||
foreach (var step in job.Steps)
|
||||
{
|
||||
if (byId.TryGetValue(step.CarId, out var car))
|
||||
step.Ip = car.Ip;
|
||||
}
|
||||
|
||||
if (job.RequireLatencyCheck)
|
||||
{
|
||||
var threshold = settings.RttThresholdMs;
|
||||
var overMode = settings.OverThreshold;
|
||||
foreach (var carId in job.CarIds.Distinct())
|
||||
{
|
||||
var ip = job.Steps.FirstOrDefault(s => s.CarId == carId)?.Ip;
|
||||
if (string.IsNullOrEmpty(ip))
|
||||
{
|
||||
SkipCar(job, carId, "无 IP");
|
||||
continue;
|
||||
}
|
||||
var rtt = await _wd.MeasureRttMsAsync(ip, cts.Token);
|
||||
if (rtt == null || rtt > threshold)
|
||||
{
|
||||
if (string.Equals(overMode, "skip", StringComparison.OrdinalIgnoreCase) || rtt == null)
|
||||
SkipCar(job, carId, rtt == null ? "延迟探测失败" : $"RTT {rtt}ms > {threshold}ms");
|
||||
}
|
||||
}
|
||||
_store.SaveJob(job);
|
||||
}
|
||||
|
||||
job.Status = "running";
|
||||
_store.SaveJob(job);
|
||||
|
||||
var maxCar = Math.Max(1, settings.MaxCar);
|
||||
var carGroups = job.CarIds.Distinct()
|
||||
.Where(id => job.Steps.Any(s => s.CarId == id && s.Status == "pending"))
|
||||
.Chunk(maxCar);
|
||||
|
||||
foreach (var batch in carGroups)
|
||||
{
|
||||
if (cts.IsCancellationRequested) break;
|
||||
var tasks = batch.Select(carId => RunCarAsync(job, jobLock, carId, settings.BandwidthKbps, cts.Token));
|
||||
await Task.WhenAll(tasks);
|
||||
}
|
||||
|
||||
lock (jobLock)
|
||||
{
|
||||
if (cts.IsCancellationRequested)
|
||||
{
|
||||
foreach (var s in job.Steps.Where(s => s.Status is "pending" or "running"))
|
||||
{
|
||||
s.Status = "skipped";
|
||||
s.Error ??= "已取消";
|
||||
}
|
||||
job.Status = "cancelled";
|
||||
job.FinishedAt = DateTimeOffset.UtcNow;
|
||||
Recalc(job);
|
||||
}
|
||||
else
|
||||
{
|
||||
Finalize(job);
|
||||
}
|
||||
_store.SaveJob(job);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_log.LogError(ex, "OTA job {Id} crashed", jobId);
|
||||
var job = _store.GetJob(jobId);
|
||||
if (job != null)
|
||||
{
|
||||
job.Status = "failed";
|
||||
job.Message = ex.Message;
|
||||
job.FinishedAt = DateTimeOffset.UtcNow;
|
||||
_store.SaveJob(job);
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
_running.TryRemove(jobId, out _);
|
||||
cts.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
private static void SkipCar(OtaJob job, string carId, string reason)
|
||||
{
|
||||
foreach (var step in job.Steps.Where(s => s.CarId == carId && s.Status == "pending"))
|
||||
{
|
||||
step.Status = "skipped";
|
||||
step.Error = reason;
|
||||
}
|
||||
}
|
||||
|
||||
private static List<OtaCustomFileItem> ResolveCustomFiles(OtaJob job)
|
||||
{
|
||||
if (job.CustomFiles is { Count: > 0 })
|
||||
return job.CustomFiles.Where(f => File.Exists(f.LocalPath)).ToList();
|
||||
if (!string.IsNullOrEmpty(job.CustomLocalPath) && File.Exists(job.CustomLocalPath))
|
||||
{
|
||||
return new List<OtaCustomFileItem>
|
||||
{
|
||||
new()
|
||||
{
|
||||
LocalPath = job.CustomLocalPath,
|
||||
FileName = job.CustomFileName ?? Path.GetFileName(job.CustomLocalPath)
|
||||
}
|
||||
};
|
||||
}
|
||||
return new();
|
||||
}
|
||||
|
||||
private async Task UploadCustomFilesForCarAsync(OtaJob job, string ip, int bandwidth, CancellationToken ct)
|
||||
{
|
||||
var files = ResolveCustomFiles(job);
|
||||
if (files.Count == 0) throw new InvalidOperationException("自定义文件已丢失");
|
||||
var remote = job.CustomRemotePath ?? "";
|
||||
var ops = job.CustomRestartOps is { Count: > 0 }
|
||||
? job.CustomRestartOps
|
||||
: new List<int> { job.CustomRestartOp ?? -1 };
|
||||
|
||||
// 非末文件一律 -1;末文件按所选重启项依次再传(对齐 CarOTA.App)
|
||||
for (var i = 0; i < files.Count; i++)
|
||||
{
|
||||
var f = files[i];
|
||||
var isLast = i == files.Count - 1;
|
||||
if (!isLast)
|
||||
{
|
||||
await _wd.UploadCustomFileAsync(ip, f.LocalPath, f.FileName, remote, -1, bandwidth, ct);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (ops.Count == 1 && ops[0] == -1)
|
||||
{
|
||||
await _wd.UploadCustomFileAsync(ip, f.LocalPath, f.FileName, remote, -1, bandwidth, ct);
|
||||
}
|
||||
else
|
||||
{
|
||||
foreach (var op in ops.Where(o => o != -1).DefaultIfEmpty(-1))
|
||||
await _wd.UploadCustomFileAsync(ip, f.LocalPath, f.FileName, remote, op, bandwidth, ct);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async Task RunCarAsync(OtaJob job, object jobLock, string carId, int bandwidth, CancellationToken ct)
|
||||
{
|
||||
// 只处理本车 step;同批其他车任务并行修改各自 step,共享同一 job 实例,写盘统一在 jobLock 下串行。
|
||||
var steps = job.Steps.Where(s => s.CarId == carId && s.Status == "pending").ToList();
|
||||
if (steps.Count == 0) return;
|
||||
var ip = steps[0].Ip;
|
||||
if (string.IsNullOrEmpty(ip))
|
||||
{
|
||||
lock (jobLock)
|
||||
{
|
||||
foreach (var s in steps) { s.Status = "failed"; s.Error = "无 IP"; }
|
||||
Recalc(job);
|
||||
_store.SaveJob(job);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (var step in steps)
|
||||
{
|
||||
if (ct.IsCancellationRequested)
|
||||
{
|
||||
lock (jobLock)
|
||||
{
|
||||
step.Status = "skipped";
|
||||
step.Error = "已取消";
|
||||
Recalc(job);
|
||||
_store.SaveJob(job);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
lock (jobLock)
|
||||
{
|
||||
step.Status = "running";
|
||||
Recalc(job);
|
||||
_store.SaveJob(job);
|
||||
}
|
||||
try
|
||||
{
|
||||
switch (job.Kind)
|
||||
{
|
||||
case "sync":
|
||||
{
|
||||
var target = _store.GetTarget() ?? throw new InvalidOperationException("目标版本丢失");
|
||||
if (!target.Components.TryGetValue(step.Component, out var art))
|
||||
throw new InvalidOperationException($"组件 {step.Component} 不在目标中");
|
||||
await _wd.UploadComponentAsync(ip, step.Component, art.Path, art.FileName, bandwidth, ct);
|
||||
break;
|
||||
}
|
||||
case "customFile":
|
||||
await UploadCustomFilesForCarAsync(job, ip, bandwidth, ct);
|
||||
break;
|
||||
case "configPush":
|
||||
await _wd.PutJsonAsync(ip, job.ConfigApp!, job.ConfigJson!, ct);
|
||||
break;
|
||||
}
|
||||
lock (jobLock)
|
||||
{
|
||||
step.Status = "succeeded";
|
||||
step.Error = null;
|
||||
Recalc(job);
|
||||
_store.SaveJob(job);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
lock (jobLock)
|
||||
{
|
||||
step.Status = ct.IsCancellationRequested ? "skipped" : "failed";
|
||||
step.Error = ct.IsCancellationRequested ? "已取消" : ex.Message;
|
||||
Recalc(job);
|
||||
_store.SaveJob(job);
|
||||
}
|
||||
_log.LogWarning(ex, "OTA step fail {Job} {Car} {Comp}", job.Id, carId, step.Component);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void Recalc(OtaJob job)
|
||||
{
|
||||
job.DoneSteps = job.Steps.Count(s => s.Status is "succeeded" or "failed" or "skipped");
|
||||
}
|
||||
|
||||
private static void Finalize(OtaJob job)
|
||||
{
|
||||
Recalc(job);
|
||||
job.FinishedAt = DateTimeOffset.UtcNow;
|
||||
var anyFail = job.Steps.Any(s => s.Status == "failed");
|
||||
var anyOk = job.Steps.Any(s => s.Status == "succeeded");
|
||||
var anyPending = job.Steps.Any(s => s.Status is "pending" or "running");
|
||||
if (job.Status == "cancelled") return;
|
||||
if (anyPending) job.Status = "partial";
|
||||
else if (anyFail && anyOk) job.Status = "partial";
|
||||
else if (anyFail) job.Status = "failed";
|
||||
else if (anyOk) job.Status = "succeeded";
|
||||
else job.Status = "cancelled";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
namespace MiGu.Server.Ota;
|
||||
|
||||
public sealed class OtaSettings
|
||||
{
|
||||
public int BandwidthKbps { get; set; }
|
||||
public int MaxCar { get; set; } = 2;
|
||||
public bool LatencyEnabled { get; set; }
|
||||
public int RttThresholdMs { get; set; } = 200;
|
||||
/// <summary>skip | confirm</summary>
|
||||
public string OverThreshold { get; set; } = "skip";
|
||||
public int BackupPeriodMinutes { get; set; } = 60;
|
||||
public bool BackupExe { get; set; }
|
||||
public string? NewVersionName { get; set; }
|
||||
}
|
||||
|
||||
public sealed class OtaFileArtifact
|
||||
{
|
||||
public string Hash { get; set; } = "";
|
||||
public string Path { get; set; } = "";
|
||||
public string FileName { get; set; } = "";
|
||||
public string? Time { get; set; }
|
||||
public long Size { get; set; }
|
||||
}
|
||||
|
||||
public sealed class OtaTarget
|
||||
{
|
||||
public string PackageId { get; set; } = "";
|
||||
public string? Name { get; set; }
|
||||
public DateTimeOffset ActivatedAt { get; set; }
|
||||
public Dictionary<string, OtaFileArtifact> Components { get; set; } = new(StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
public sealed class OtaPackageInfo
|
||||
{
|
||||
public string Id { get; set; } = "";
|
||||
public string? SourceIp { get; set; }
|
||||
public DateTimeOffset CreatedAt { get; set; }
|
||||
public long TotalBytes { get; set; }
|
||||
public bool IsTarget { get; set; }
|
||||
public Dictionary<string, OtaFileArtifact> Components { get; set; } = new(StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
public sealed class OtaComponentVersion
|
||||
{
|
||||
public string? Version { get; set; }
|
||||
public string? Time { get; set; }
|
||||
}
|
||||
|
||||
public sealed class OtaAppVersions
|
||||
{
|
||||
public OtaComponentVersion? Exe { get; set; }
|
||||
public OtaComponentVersion? Dll { get; set; }
|
||||
public OtaComponentVersion? Pdb { get; set; }
|
||||
}
|
||||
|
||||
public sealed class OtaVehicleRow
|
||||
{
|
||||
public string Id { get; set; } = "";
|
||||
public string Name { get; set; } = "";
|
||||
public string? Ip { get; set; }
|
||||
public string? State { get; set; }
|
||||
public string? Group { get; set; }
|
||||
public bool Reachable { get; set; }
|
||||
public int? RttMs { get; set; }
|
||||
public OtaAppVersions? Medulla { get; set; }
|
||||
public OtaAppVersions? Detour { get; set; }
|
||||
public OtaAppVersions? Clumsy { get; set; }
|
||||
public Dictionary<string, string>? Match { get; set; }
|
||||
}
|
||||
|
||||
public sealed class OtaJobStep
|
||||
{
|
||||
public string CarId { get; set; } = "";
|
||||
public string? Ip { get; set; }
|
||||
public string Component { get; set; } = "";
|
||||
public string Status { get; set; } = "pending";
|
||||
public string? Error { get; set; }
|
||||
}
|
||||
|
||||
public sealed class OtaJob
|
||||
{
|
||||
public string Id { get; set; } = "";
|
||||
/// <summary>sync | customFile | configPush</summary>
|
||||
public string Kind { get; set; } = "sync";
|
||||
public string Status { get; set; } = "pending";
|
||||
public DateTimeOffset CreatedAt { get; set; }
|
||||
public DateTimeOffset? FinishedAt { get; set; }
|
||||
public string? CreatedBy { get; set; }
|
||||
public string? PackageId { get; set; }
|
||||
public List<string> CarIds { get; set; } = new();
|
||||
public List<string> Components { get; set; } = new();
|
||||
public bool RequireLatencyCheck { get; set; }
|
||||
public int DoneSteps { get; set; }
|
||||
public int TotalSteps { get; set; }
|
||||
public List<OtaJobStep> Steps { get; set; } = new();
|
||||
public string? Message { get; set; }
|
||||
// customFile
|
||||
public string? CustomFileName { get; set; }
|
||||
public string? CustomRemotePath { get; set; }
|
||||
public int? CustomRestartOp { get; set; }
|
||||
public string? CustomLocalPath { get; set; }
|
||||
public List<OtaCustomFileItem> CustomFiles { get; set; } = new();
|
||||
public List<int> CustomRestartOps { get; set; } = new() { -1 };
|
||||
// configPush
|
||||
public string? ConfigApp { get; set; }
|
||||
public string? ConfigJson { get; set; }
|
||||
}
|
||||
|
||||
public sealed class CreateSyncJobRequest
|
||||
{
|
||||
public List<string> CarIds { get; set; } = new();
|
||||
public List<string>? Components { get; set; }
|
||||
public bool? RequireLatencyCheck { get; set; }
|
||||
}
|
||||
|
||||
public sealed class PullPackageRequest
|
||||
{
|
||||
public string CarId { get; set; } = "";
|
||||
}
|
||||
|
||||
public sealed class CreateCustomFileJobRequest
|
||||
{
|
||||
public List<string> CarIds { get; set; } = new();
|
||||
public string RemotePath { get; set; } = "";
|
||||
/// <summary>兼容旧单值;优先用 RestartOps。</summary>
|
||||
public int RestartOp { get; set; } = -1;
|
||||
/// <summary>-1 不重启;0 Medulla;1 Clumsy;2 Detour;3 WatchDog。可多选。</summary>
|
||||
public List<int>? RestartOps { get; set; }
|
||||
public bool? RequireLatencyCheck { get; set; }
|
||||
}
|
||||
|
||||
public sealed class OtaCustomFileItem
|
||||
{
|
||||
public string LocalPath { get; set; } = "";
|
||||
public string FileName { get; set; } = "";
|
||||
}
|
||||
|
||||
public sealed class CreateConfigPushRequest
|
||||
{
|
||||
public List<string> CarIds { get; set; } = new();
|
||||
public string App { get; set; } = "";
|
||||
public string Json { get; set; } = "";
|
||||
public bool? RequireLatencyCheck { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
namespace MiGu.Server.Ota;
|
||||
|
||||
public sealed class OtaOptions
|
||||
{
|
||||
/// <summary>相对 ContentRoot 或绝对路径;默认 data/ota</summary>
|
||||
public string DataRoot { get; set; } = "data/ota";
|
||||
|
||||
public int WatchDogPort { get; set; } = 9776;
|
||||
|
||||
public int RequestTimeoutMs { get; set; } = 60_000;
|
||||
|
||||
public int UploadTimeoutMs { get; set; } = 600_000;
|
||||
|
||||
/// <summary>
|
||||
/// WatchDog 回传监听端口(写死连 :8000)。MiGu 会额外监听该端口并挂 /upload-mdcs/*。
|
||||
/// </summary>
|
||||
public int ReceivePort { get; set; } = 8000;
|
||||
|
||||
/// <summary>
|
||||
/// 可选:本机对车辆可见的管理面基址(如 http://192.168.1.10:8080)。
|
||||
/// 注意:现网 WatchDog 忽略 getmdcsexe 的 server 参数,仍回传到 config.serverIP:ReceivePort。
|
||||
/// </summary>
|
||||
public string? PublicBaseUrl { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
namespace MiGu.Server.Ota;
|
||||
|
||||
/// <summary>MDC 组件文件映射(对齐参考工具 MDCPath.json)。</summary>
|
||||
public static class OtaPathMap
|
||||
{
|
||||
public static readonly IReadOnlyDictionary<string, string[]> Default = new Dictionary<string, string[]>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["M"] = new[] { "Medulla.exe", "plugins\\CartActivator.dll", "plugins\\CartActivator.pdb" },
|
||||
["D"] = new[] { "Detour.exe" },
|
||||
["C"] = new[] { "ClumsyConsole.exe", "FG2305014_C.dll", "FG2305014_C.pdb" }
|
||||
};
|
||||
|
||||
public static readonly string[] ComponentKeys =
|
||||
{
|
||||
"M.exe", "M.dll", "M.pdb", "D.exe", "C.exe", "C.dll", "C.pdb"
|
||||
};
|
||||
|
||||
public static string? RelPathFor(string componentKey)
|
||||
{
|
||||
return componentKey switch
|
||||
{
|
||||
"M.exe" => "M/Medulla.exe",
|
||||
"M.dll" => "M/plugins/CartActivator.dll",
|
||||
"M.pdb" => "M/plugins/CartActivator.pdb",
|
||||
"D.exe" => "D/Detour.exe",
|
||||
"C.exe" => "C/ClumsyConsole.exe",
|
||||
"C.dll" => "C/FG2305014_C.dll",
|
||||
"C.pdb" => "C/FG2305014_C.pdb",
|
||||
_ => null
|
||||
};
|
||||
}
|
||||
|
||||
public static string? WatchDogUpdatePath(string componentKey) => componentKey switch
|
||||
{
|
||||
"M.exe" => "updateMedullaExecutable",
|
||||
"M.dll" => "updateMedullaDll",
|
||||
"M.pdb" => "UpdateMedullaPdb",
|
||||
"D.exe" => "updateDetourExecutable",
|
||||
"C.exe" => "updateClumsyExecutable",
|
||||
"C.dll" => "updateClumsyDll",
|
||||
"C.pdb" => "UpdateClumsyPdb",
|
||||
_ => null
|
||||
};
|
||||
|
||||
public static string AppFolder(string componentKey) => componentKey.StartsWith('M') ? "M"
|
||||
: componentKey.StartsWith('D') ? "D" : "C";
|
||||
|
||||
public static string ExtKey(string componentKey)
|
||||
{
|
||||
var i = componentKey.IndexOf('.');
|
||||
return i >= 0 ? componentKey[(i + 1)..] : componentKey;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,368 @@
|
||||
using System.Text.Json;
|
||||
using MiGu.Server.Infra;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace MiGu.Server.Ota;
|
||||
|
||||
public sealed class OtaStore
|
||||
{
|
||||
private readonly object _gate = new();
|
||||
private readonly ILogger<OtaStore> _log;
|
||||
private readonly JsonSerializerOptions _json = new()
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
WriteIndented = true
|
||||
};
|
||||
|
||||
public string Root { get; }
|
||||
public string PackagesDir { get; }
|
||||
public string JobsDir { get; }
|
||||
public string HistoryDir { get; }
|
||||
public string TargetFile { get; }
|
||||
public string SettingsFile { get; }
|
||||
|
||||
private string? _lastPullId;
|
||||
private readonly System.Collections.Concurrent.ConcurrentDictionary<string, string> _pullByIp =
|
||||
new(StringComparer.OrdinalIgnoreCase);
|
||||
private long _jobSeq;
|
||||
|
||||
public OtaStore(IWebHostEnvironment env, IOptions<OtaOptions> options, ILogger<OtaStore> log)
|
||||
{
|
||||
_log = log;
|
||||
var cfg = options.Value.DataRoot;
|
||||
Root = Path.IsPathRooted(cfg) ? cfg : Path.Combine(env.ContentRootPath, cfg);
|
||||
PackagesDir = Path.Combine(Root, "packages");
|
||||
JobsDir = Path.Combine(Root, "jobs");
|
||||
HistoryDir = Path.Combine(Root, "history");
|
||||
TargetFile = Path.Combine(Root, "target.json");
|
||||
SettingsFile = Path.Combine(Root, "settings.json");
|
||||
Directory.CreateDirectory(PackagesDir);
|
||||
Directory.CreateDirectory(JobsDir);
|
||||
Directory.CreateDirectory(HistoryDir);
|
||||
}
|
||||
|
||||
public OtaSettings GetSettings()
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
if (!File.Exists(SettingsFile)) return new OtaSettings();
|
||||
try
|
||||
{
|
||||
return JsonSerializer.Deserialize<OtaSettings>(File.ReadAllText(SettingsFile), _json) ?? new OtaSettings();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_log.LogWarning(ex, "OTA settings load failed");
|
||||
return new OtaSettings();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public OtaSettings SaveSettings(OtaSettings settings)
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
AtomicFile.WriteAllText(SettingsFile, JsonSerializer.Serialize(settings, _json));
|
||||
return settings;
|
||||
}
|
||||
}
|
||||
|
||||
public OtaTarget? GetTarget()
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
if (!File.Exists(TargetFile)) return null;
|
||||
try
|
||||
{
|
||||
return JsonSerializer.Deserialize<OtaTarget>(File.ReadAllText(TargetFile), _json);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_log.LogWarning(ex, "OTA target load failed");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public void SetTarget(OtaTarget target)
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
AtomicFile.WriteAllText(TargetFile, JsonSerializer.Serialize(target, _json));
|
||||
var settings = GetSettingsUnlocked();
|
||||
if (!string.IsNullOrWhiteSpace(target.Name))
|
||||
{
|
||||
settings.NewVersionName = target.Name;
|
||||
AtomicFile.WriteAllText(SettingsFile, JsonSerializer.Serialize(settings, _json));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private OtaSettings GetSettingsUnlocked()
|
||||
{
|
||||
if (!File.Exists(SettingsFile)) return new OtaSettings();
|
||||
try
|
||||
{
|
||||
return JsonSerializer.Deserialize<OtaSettings>(File.ReadAllText(SettingsFile), _json) ?? new OtaSettings();
|
||||
}
|
||||
catch
|
||||
{
|
||||
return new OtaSettings();
|
||||
}
|
||||
}
|
||||
|
||||
public string BeginPullPackage(string? sourceIp)
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
var baseId = $"{DateTime.UtcNow:yyyyMMddHHmmssfff}({SafeIdPart(sourceIp ?? "upload")})";
|
||||
var id = baseId;
|
||||
var dir = Path.Combine(PackagesDir, id);
|
||||
for (var i = 1; Directory.Exists(dir); i++)
|
||||
{
|
||||
id = $"{baseId}-{i:D2}";
|
||||
dir = Path.Combine(PackagesDir, id);
|
||||
}
|
||||
Directory.CreateDirectory(dir);
|
||||
foreach (var app in new[] { "M", "D", "C" })
|
||||
Directory.CreateDirectory(Path.Combine(dir, app));
|
||||
Directory.CreateDirectory(Path.Combine(dir, "M", "plugins"));
|
||||
var ip = NormalizeIp(sourceIp);
|
||||
if (ip != null) _pullByIp[ip] = id;
|
||||
_lastPullId = id;
|
||||
return id;
|
||||
}
|
||||
}
|
||||
|
||||
public string? ActivePullId
|
||||
{
|
||||
get { lock (_gate) return _lastPullId; }
|
||||
}
|
||||
|
||||
public bool TryGetActivePullId(string? sourceIp, out string? id)
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
var ip = NormalizeIp(sourceIp);
|
||||
if (ip != null && _pullByIp.TryGetValue(ip, out var byIp))
|
||||
{
|
||||
id = byIp;
|
||||
return true;
|
||||
}
|
||||
|
||||
if (ip == null && _lastPullId != null)
|
||||
{
|
||||
id = _lastPullId;
|
||||
return true;
|
||||
}
|
||||
|
||||
id = null;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public void ClearActivePull(string? sourceIp = null)
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
var ip = NormalizeIp(sourceIp);
|
||||
if (ip != null) _pullByIp.TryRemove(ip, out _);
|
||||
if (_pullByIp.IsEmpty) _lastPullId = null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>把 WatchDog 回传连接的远端 IP 归一(去掉 IPv6 映射前缀,如 ::ffff:192.168.1.13)。</summary>
|
||||
private static string? NormalizeIp(string? ip)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(ip)) return null;
|
||||
if (System.Net.IPAddress.TryParse(ip, out var addr))
|
||||
return (addr.IsIPv4MappedToIPv6 ? addr.MapToIPv4() : addr).ToString();
|
||||
return ip.Trim();
|
||||
}
|
||||
|
||||
public string ResolveReceivePath(string routeKey, string? clientIp = null)
|
||||
{
|
||||
// routeKey examples: Medullaexe, Medulladll, Detourexe, ClumsyConsoleexe, ClumsyConsoledll
|
||||
// Do not fall back to the latest session for a known-but-unregistered client.
|
||||
if (!TryGetActivePullId(clientIp, out var id) || id == null)
|
||||
throw new InvalidOperationException("无进行中的拉取会话");
|
||||
var dir = Path.Combine(PackagesDir, id);
|
||||
return routeKey.ToLowerInvariant() switch
|
||||
{
|
||||
"medullaexe" => Path.Combine(dir, "M", "Medulla.exe"),
|
||||
"medulladll" => Path.Combine(dir, "M", "plugins", "CartActivator.dll"),
|
||||
"medullapdb" => Path.Combine(dir, "M", "plugins", "CartActivator.pdb"),
|
||||
"detourexe" => Path.Combine(dir, "D", "Detour.exe"),
|
||||
"clumsyconsoleexe" or "clumsyexe" => Path.Combine(dir, "C", "ClumsyConsole.exe"),
|
||||
"clumsyconsoledll" or "clumsydll" => Path.Combine(dir, "C", "FG2305014_C.dll"),
|
||||
"clumsyconsolepdb" or "clumsypdb" => Path.Combine(dir, "C", "FG2305014_C.pdb"),
|
||||
_ => throw new ArgumentException($"未知接收路由: {routeKey}")
|
||||
};
|
||||
}
|
||||
|
||||
public OtaPackageInfo ScanPackage(string id)
|
||||
{
|
||||
var dir = ResolveUnder(PackagesDir, id);
|
||||
if (!Directory.Exists(dir)) throw new DirectoryNotFoundException(id);
|
||||
var info = new OtaPackageInfo
|
||||
{
|
||||
Id = id,
|
||||
CreatedAt = Directory.GetCreationTimeUtc(dir),
|
||||
SourceIp = ExtractSourceIp(id)
|
||||
};
|
||||
long total = 0;
|
||||
foreach (var key in OtaPathMap.ComponentKeys)
|
||||
{
|
||||
var rel = OtaPathMap.RelPathFor(key);
|
||||
if (rel == null) continue;
|
||||
var path = Path.Combine(dir, rel.Replace('/', Path.DirectorySeparatorChar));
|
||||
if (!File.Exists(path)) continue;
|
||||
var fi = new FileInfo(path);
|
||||
total += fi.Length;
|
||||
info.Components[key] = new OtaFileArtifact
|
||||
{
|
||||
Hash = OtaHash.OfFile(path),
|
||||
Path = path,
|
||||
FileName = Path.GetFileName(path),
|
||||
Time = fi.LastWriteTime.ToString("yyyy-MM-dd HH:mm:ss"),
|
||||
Size = fi.Length
|
||||
};
|
||||
}
|
||||
info.TotalBytes = total;
|
||||
var target = GetTarget();
|
||||
info.IsTarget = target != null && string.Equals(target.PackageId, id, StringComparison.Ordinal);
|
||||
return info;
|
||||
}
|
||||
|
||||
public List<OtaPackageInfo> ListPackages()
|
||||
{
|
||||
if (!Directory.Exists(PackagesDir)) return new();
|
||||
var list = new List<OtaPackageInfo>();
|
||||
foreach (var dir in Directory.GetDirectories(PackagesDir).OrderByDescending(d => d))
|
||||
{
|
||||
try { list.Add(ScanPackage(Path.GetFileName(dir))); }
|
||||
catch (Exception ex) { _log.LogDebug(ex, "skip package {Dir}", dir); }
|
||||
}
|
||||
return list;
|
||||
}
|
||||
|
||||
public void DeletePackage(string id)
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
var target = GetTarget();
|
||||
if (target != null && string.Equals(target.PackageId, id, StringComparison.Ordinal))
|
||||
throw new InvalidOperationException("不能删除当前目标版本");
|
||||
var dir = ResolveUnder(PackagesDir, id);
|
||||
if (Directory.Exists(dir)) Directory.Delete(dir, true);
|
||||
}
|
||||
}
|
||||
|
||||
public OtaTarget ActivatePackage(string id, string? name)
|
||||
{
|
||||
var pkg = ScanPackage(id);
|
||||
if (pkg.Components.Count == 0)
|
||||
throw new InvalidOperationException("包内无有效组件文件");
|
||||
var safeId = RequireSafeId(id);
|
||||
var target = new OtaTarget
|
||||
{
|
||||
PackageId = safeId,
|
||||
Name = name ?? safeId,
|
||||
ActivatedAt = DateTimeOffset.UtcNow,
|
||||
Components = pkg.Components
|
||||
};
|
||||
SetTarget(target);
|
||||
return target;
|
||||
}
|
||||
|
||||
public string PackageDir(string id) => ResolveUnder(PackagesDir, id);
|
||||
|
||||
public void SaveJob(OtaJob job)
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
var path = ResolveUnder(JobsDir, $"{RequireSafeId(job.Id)}.json");
|
||||
AtomicFile.WriteAllText(path, JsonSerializer.Serialize(job, _json));
|
||||
}
|
||||
}
|
||||
|
||||
public OtaJob? GetJob(string id)
|
||||
{
|
||||
var path = ResolveUnder(JobsDir, $"{RequireSafeId(id)}.json");
|
||||
if (!File.Exists(path)) return null;
|
||||
try
|
||||
{
|
||||
return JsonSerializer.Deserialize<OtaJob>(File.ReadAllText(path), _json);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_log.LogWarning(ex, "job load failed {Id}", id);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public List<OtaJob> ListJobs(int take = 100)
|
||||
{
|
||||
if (!Directory.Exists(JobsDir)) return new();
|
||||
return Directory.GetFiles(JobsDir, "*.json")
|
||||
.Select(f =>
|
||||
{
|
||||
try { return JsonSerializer.Deserialize<OtaJob>(File.ReadAllText(f), _json); }
|
||||
catch { return null; }
|
||||
})
|
||||
.Where(j => j != null)
|
||||
.Cast<OtaJob>()
|
||||
.OrderByDescending(j => j.CreatedAt)
|
||||
.Take(take)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
public string NextJobId()
|
||||
{
|
||||
// 进程内自增序号保证唯一(同秒也不会撞 ID → 不会两个 job 写同一文件)。
|
||||
var seq = System.Threading.Interlocked.Increment(ref _jobSeq);
|
||||
return $"J{DateTime.UtcNow:yyyyMMddHHmmss}-{seq:D4}";
|
||||
}
|
||||
|
||||
private static string? ExtractSourceIp(string id)
|
||||
{
|
||||
var open = id.IndexOf('(');
|
||||
var close = id.IndexOf(')');
|
||||
if (open >= 0 && close > open) return id[(open + 1)..close];
|
||||
return null;
|
||||
}
|
||||
|
||||
private static string SafeIdPart(string raw)
|
||||
{
|
||||
var safe = raw.Trim();
|
||||
foreach (var ch in Path.GetInvalidFileNameChars())
|
||||
safe = safe.Replace(ch, '_');
|
||||
return string.IsNullOrWhiteSpace(safe) ? "upload" : safe;
|
||||
}
|
||||
|
||||
/// <summary>拒绝路径段(含 .. / 分隔符),只允许单层文件名。</summary>
|
||||
private static string RequireSafeId(string id)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(id))
|
||||
throw new ArgumentException("无效标识");
|
||||
var trimmed = id.Trim();
|
||||
if (trimmed is "." or ".."
|
||||
|| trimmed.Contains('/') || trimmed.Contains('\\')
|
||||
|| trimmed.IndexOfAny(Path.GetInvalidFileNameChars()) >= 0)
|
||||
throw new ArgumentException("无效标识");
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
private string ResolveUnder(string root, string id)
|
||||
{
|
||||
var safe = RequireSafeId(id);
|
||||
var fullRoot = Path.GetFullPath(root);
|
||||
var full = Path.GetFullPath(Path.Combine(fullRoot, safe));
|
||||
var prefix = fullRoot.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar)
|
||||
+ Path.DirectorySeparatorChar;
|
||||
if (!full.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)
|
||||
&& !string.Equals(full, fullRoot, StringComparison.OrdinalIgnoreCase))
|
||||
throw new ArgumentException("无效标识");
|
||||
return full;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Options;
|
||||
using MiGu.Server.Launcher;
|
||||
|
||||
namespace MiGu.Server.Ota;
|
||||
|
||||
public sealed class OtaVehicleSource
|
||||
{
|
||||
private readonly IHttpClientFactory _httpFactory;
|
||||
private readonly SimpleLiteOptions _sl;
|
||||
private readonly InternalTokenStoreAccessor _token;
|
||||
private readonly ILogger<OtaVehicleSource> _log;
|
||||
|
||||
public OtaVehicleSource(
|
||||
IHttpClientFactory httpFactory,
|
||||
IOptions<SimpleLiteOptions> sl,
|
||||
InternalTokenStoreAccessor token,
|
||||
ILogger<OtaVehicleSource> log)
|
||||
{
|
||||
_httpFactory = httpFactory;
|
||||
_sl = sl.Value;
|
||||
_token = token;
|
||||
_log = log;
|
||||
}
|
||||
|
||||
public async Task<List<OtaVehicleRow>> ListCarsAsync(CancellationToken ct)
|
||||
{
|
||||
var port = _sl.ProjectionPort > 0 ? _sl.ProjectionPort : 8222;
|
||||
var cars = await TryProjectionAsync(port, ct);
|
||||
if (cars.Count == 0)
|
||||
cars = await TryAgvListAsync(port, ct);
|
||||
return cars;
|
||||
}
|
||||
|
||||
private async Task<List<OtaVehicleRow>> TryProjectionAsync(int port, CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var client = _httpFactory.CreateClient();
|
||||
client.Timeout = TimeSpan.FromSeconds(8);
|
||||
using var req = new HttpRequestMessage(HttpMethod.Get, $"http://127.0.0.1:{port}/projection/cars");
|
||||
var token = _token.Token;
|
||||
if (!string.IsNullOrEmpty(token))
|
||||
req.Headers.TryAddWithoutValidation("X-Platform-Internal-Token", token);
|
||||
using var resp = await client.SendAsync(req, ct);
|
||||
if (!resp.IsSuccessStatusCode) return new();
|
||||
var text = await resp.Content.ReadAsStringAsync(ct);
|
||||
return ParseCars(text);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_log.LogDebug(ex, "projection/cars failed");
|
||||
return new();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<List<OtaVehicleRow>> TryAgvListAsync(int port, CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var client = _httpFactory.CreateClient();
|
||||
client.Timeout = TimeSpan.FromSeconds(8);
|
||||
using var resp = await client.GetAsync($"http://127.0.0.1:{port}/api/agv/list", ct);
|
||||
if (!resp.IsSuccessStatusCode) return new();
|
||||
var text = await resp.Content.ReadAsStringAsync(ct);
|
||||
using var doc = JsonDocument.Parse(text);
|
||||
var root = doc.RootElement;
|
||||
var arr = root.ValueKind == JsonValueKind.Array ? root
|
||||
: root.TryGetProperty("data", out var d) ? d
|
||||
: root.TryGetProperty("items", out var i) ? i
|
||||
: default;
|
||||
if (arr.ValueKind != JsonValueKind.Array) return new();
|
||||
var list = new List<OtaVehicleRow>();
|
||||
foreach (var el in arr.EnumerateArray())
|
||||
{
|
||||
var id = GetStr(el, "agv_id", "id", "carId") ?? "";
|
||||
var name = GetStr(el, "agv_name", "name") ?? id;
|
||||
var ip = GetStr(el, "agv_ip", "ip");
|
||||
var state = GetStr(el, "status", "state");
|
||||
if (string.IsNullOrEmpty(id) && string.IsNullOrEmpty(ip)) continue;
|
||||
list.Add(new OtaVehicleRow
|
||||
{
|
||||
Id = string.IsNullOrEmpty(id) ? ip! : id,
|
||||
Name = name,
|
||||
Ip = ip,
|
||||
State = state
|
||||
});
|
||||
}
|
||||
return list;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_log.LogDebug(ex, "agv/list failed");
|
||||
return new();
|
||||
}
|
||||
}
|
||||
|
||||
private static List<OtaVehicleRow> ParseCars(string text)
|
||||
{
|
||||
using var doc = JsonDocument.Parse(text);
|
||||
var root = doc.RootElement;
|
||||
var arr = root.ValueKind == JsonValueKind.Array ? root
|
||||
: root.TryGetProperty("cars", out var c) ? c
|
||||
: root.TryGetProperty("items", out var i) ? i
|
||||
: root.TryGetProperty("data", out var d) ? d
|
||||
: default;
|
||||
if (arr.ValueKind != JsonValueKind.Array) return new();
|
||||
var list = new List<OtaVehicleRow>();
|
||||
foreach (var el in arr.EnumerateArray())
|
||||
{
|
||||
var id = GetStr(el, "id", "carId", "rawId") ?? "";
|
||||
var name = GetStr(el, "name") ?? id;
|
||||
var ip = GetStr(el, "ip");
|
||||
var state = GetStr(el, "state", "status");
|
||||
var group = GetStr(el, "group");
|
||||
if (string.IsNullOrEmpty(id)) continue;
|
||||
list.Add(new OtaVehicleRow { Id = id, Name = name, Ip = ip, State = state, Group = group });
|
||||
}
|
||||
return list;
|
||||
}
|
||||
|
||||
private static string? GetStr(JsonElement el, params string[] names)
|
||||
{
|
||||
foreach (var n in names)
|
||||
{
|
||||
if (el.TryGetProperty(n, out var p) && p.ValueKind == JsonValueKind.String)
|
||||
return p.GetString();
|
||||
if (el.TryGetProperty(n, out p) && p.ValueKind is JsonValueKind.Number)
|
||||
return p.ToString();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>避免 Ota 层直接依赖 Auth 命名空间循环;薄包装 InternalTokenStore。</summary>
|
||||
public sealed class InternalTokenStoreAccessor
|
||||
{
|
||||
private readonly Auth.InternalTokenStore _store;
|
||||
public InternalTokenStoreAccessor(Auth.InternalTokenStore store) => _store = store;
|
||||
public string Token => _store.Token;
|
||||
}
|
||||
@@ -0,0 +1,289 @@
|
||||
using System.Diagnostics;
|
||||
using System.Net.Sockets;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace MiGu.Server.Ota;
|
||||
|
||||
public sealed class WatchDogClient
|
||||
{
|
||||
private readonly IHttpClientFactory _httpFactory;
|
||||
private readonly OtaOptions _opt;
|
||||
private readonly ILogger<WatchDogClient> _log;
|
||||
private readonly JsonSerializerOptions _json = new() { PropertyNameCaseInsensitive = true };
|
||||
|
||||
public WatchDogClient(IHttpClientFactory httpFactory, IOptions<OtaOptions> opt, ILogger<WatchDogClient> log)
|
||||
{
|
||||
_httpFactory = httpFactory;
|
||||
_opt = opt.Value;
|
||||
_log = log;
|
||||
}
|
||||
|
||||
private HttpClient CreateClient(int? timeoutMs = null)
|
||||
{
|
||||
var c = _httpFactory.CreateClient(nameof(WatchDogClient));
|
||||
c.Timeout = TimeSpan.FromMilliseconds(timeoutMs ?? _opt.RequestTimeoutMs);
|
||||
return c;
|
||||
}
|
||||
|
||||
private string Base(string ip) => $"http://{ip}:{_opt.WatchDogPort}";
|
||||
|
||||
public async Task<(bool Ok, OtaAppVersions? M, OtaAppVersions? D, OtaAppVersions? C, string? Error)> GetMdcInfoAsync(string ip, CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var client = CreateClient();
|
||||
using var resp = await client.GetAsync($"{Base(ip)}/getMDCInfo", ct);
|
||||
if (!resp.IsSuccessStatusCode)
|
||||
return (false, null, null, null, $"HTTP {(int)resp.StatusCode}");
|
||||
var text = await resp.Content.ReadAsStringAsync(ct);
|
||||
using var doc = JsonDocument.Parse(text);
|
||||
var root = doc.RootElement;
|
||||
return (true, ParseApp(root, "Medulla"), ParseApp(root, "Detour"), ParseApp(root, "Clumsy"), null);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return (false, null, null, null, ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
private static OtaAppVersions? ParseApp(JsonElement root, string name)
|
||||
{
|
||||
if (!root.TryGetProperty(name, out var app) && !root.TryGetProperty(name.ToLowerInvariant(), out app))
|
||||
return null;
|
||||
return new OtaAppVersions
|
||||
{
|
||||
Exe = ParseComp(app, "exe"),
|
||||
Dll = ParseComp(app, "dll"),
|
||||
Pdb = ParseComp(app, "pdb")
|
||||
};
|
||||
}
|
||||
|
||||
private static OtaComponentVersion? ParseComp(JsonElement app, string key)
|
||||
{
|
||||
if (!app.TryGetProperty(key, out var c)) return null;
|
||||
string? ver = null;
|
||||
string? time = null;
|
||||
if (c.ValueKind == JsonValueKind.Object)
|
||||
{
|
||||
if (c.TryGetProperty("version", out var v))
|
||||
ver = v.ValueKind == JsonValueKind.String ? v.GetString() : v.ToString();
|
||||
if (c.TryGetProperty("time", out var t))
|
||||
time = t.GetString();
|
||||
}
|
||||
else if (c.ValueKind == JsonValueKind.String)
|
||||
{
|
||||
ver = c.GetString();
|
||||
}
|
||||
return new OtaComponentVersion { Version = ver, Time = time };
|
||||
}
|
||||
|
||||
public async Task<int?> MeasureRttMsAsync(string ip, CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
var sw = Stopwatch.StartNew();
|
||||
using var tcp = new TcpClient();
|
||||
using var reg = ct.Register(() => { try { tcp.Close(); } catch { /* ignore */ } });
|
||||
var connectTask = tcp.ConnectAsync(ip, _opt.WatchDogPort);
|
||||
var done = await Task.WhenAny(connectTask, Task.Delay(Math.Min(3000, _opt.RequestTimeoutMs), ct));
|
||||
if (done != connectTask || !tcp.Connected) return null;
|
||||
await connectTask;
|
||||
sw.Stop();
|
||||
return (int)sw.ElapsedMilliseconds;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task TriggerPullAsync(string ip, string serverBaseUrl, string time, CancellationToken ct)
|
||||
{
|
||||
// 现网 WatchDog 忽略 server 查询参数,固定 POST 到 http://{config.serverIP}:8000/upload-mdcs/{key}。
|
||||
// serverBaseUrl 仅作日志/未来兼容;真正要通必须:车上 serverIP=本机局域网 IP,且本机监听 ReceivePort。
|
||||
using var client = CreateClient(_opt.UploadTimeoutMs);
|
||||
var url = $"{Base(ip)}/getmdcsexe?time={Uri.EscapeDataString(time)}&server={Uri.EscapeDataString(serverBaseUrl.TrimEnd('/'))}";
|
||||
try
|
||||
{
|
||||
using var resp = await client.GetAsync(url, ct);
|
||||
var body = (await resp.Content.ReadAsStringAsync(ct)).Trim();
|
||||
_log.LogInformation("getmdcsexe {Ip} -> {Code} body={Body} (WatchDog will POST to its config.serverIP:{Port}/upload-mdcs/*; expect receiver {Base})",
|
||||
ip, (int)resp.StatusCode, body.Length > 200 ? body[..200] : body, _opt.ReceivePort, serverBaseUrl);
|
||||
if (!resp.IsSuccessStatusCode)
|
||||
throw new InvalidOperationException($"WatchDog getmdcsexe HTTP {(int)resp.StatusCode}: {body}");
|
||||
if (body.Contains("请配置", StringComparison.Ordinal)
|
||||
|| body.Equals("false", StringComparison.OrdinalIgnoreCase)
|
||||
|| body.Equals("\"false\"", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
"WatchDog 拒绝拉包或回传失败。请确认:1) 车已配置 Medulla/Detour/Clumsy 路径;" +
|
||||
$"2) watch_dog.json 的 serverIP 指向本机局域网 IP(车将 POST 到 serverIP:{_opt.ReceivePort}/upload-mdcs/*);" +
|
||||
$"3) 本机已监听 :{_opt.ReceivePort}。WatchDog 返回:{body}");
|
||||
}
|
||||
}
|
||||
catch (InvalidOperationException)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_log.LogWarning(ex, "getmdcsexe failed {Ip}", ip);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task UploadComponentAsync(string ip, string componentKey, string localPath, string fileName, int bandwidthKbps, CancellationToken ct)
|
||||
{
|
||||
var endpoint = OtaPathMap.WatchDogUpdatePath(componentKey)
|
||||
?? throw new ArgumentException($"未知组件 {componentKey}");
|
||||
await UploadFileAsync($"{Base(ip)}/{endpoint}", localPath, fileName, bandwidthKbps, null, ct);
|
||||
}
|
||||
|
||||
public async Task UploadCustomFileAsync(string ip, string localPath, string fileName, string remotePath, int restartOp, int bandwidthKbps, CancellationToken ct)
|
||||
{
|
||||
var url = $"{Base(ip)}/updateFile/{Uri.EscapeDataString(fileName)}/{restartOp}/";
|
||||
await UploadFileAsync(url, localPath, fileName, bandwidthKbps, new Dictionary<string, string> { ["path"] = remotePath }, ct);
|
||||
}
|
||||
|
||||
private async Task UploadFileAsync(string url, string localPath, string fileName, int bandwidthKbps, Dictionary<string, string>? extraFields, CancellationToken ct)
|
||||
{
|
||||
using var client = CreateClient(_opt.UploadTimeoutMs);
|
||||
await using var fs = File.OpenRead(localPath);
|
||||
Stream contentStream = fs;
|
||||
if (bandwidthKbps > 0)
|
||||
contentStream = new ThrottledStream(fs, bandwidthKbps * 1024L);
|
||||
|
||||
using var form = new MultipartFormDataContent();
|
||||
if (extraFields != null)
|
||||
{
|
||||
foreach (var (k, v) in extraFields)
|
||||
form.Add(new StringContent(v, Encoding.UTF8), k);
|
||||
}
|
||||
var streamContent = new StreamContent(contentStream);
|
||||
form.Add(streamContent, "file", fileName);
|
||||
|
||||
using var resp = await client.PostAsync(url, form, ct);
|
||||
if (!resp.IsSuccessStatusCode)
|
||||
{
|
||||
var body = await resp.Content.ReadAsStringAsync(ct);
|
||||
throw new InvalidOperationException($"上传失败 HTTP {(int)resp.StatusCode}: {body}");
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<string> GetJsonAsync(string ip, string app, CancellationToken ct)
|
||||
{
|
||||
var path = app.ToLowerInvariant() switch
|
||||
{
|
||||
"medulla" => "getMedullajson",
|
||||
"detour" => "getDetourjson",
|
||||
"clumsy" => "getClumsyjson",
|
||||
_ => throw new ArgumentException("app 须为 medulla|detour|clumsy")
|
||||
};
|
||||
using var client = CreateClient();
|
||||
using var resp = await client.GetAsync($"{Base(ip)}/{path}", ct);
|
||||
resp.EnsureSuccessStatusCode();
|
||||
return await resp.Content.ReadAsStringAsync(ct);
|
||||
}
|
||||
|
||||
public async Task PutJsonAsync(string ip, string app, string json, CancellationToken ct)
|
||||
{
|
||||
var path = app.ToLowerInvariant() switch
|
||||
{
|
||||
"medulla" => "updateMedullajson",
|
||||
"detour" => "updateDetourjson",
|
||||
"clumsy" => "updateClumsyjson",
|
||||
_ => throw new ArgumentException("app 须为 medulla|detour|clumsy")
|
||||
};
|
||||
using var client = CreateClient();
|
||||
using var content = new StringContent(json, Encoding.UTF8, "application/json");
|
||||
using var resp = await client.PostAsync($"{Base(ip)}/{path}", content, ct);
|
||||
if (!resp.IsSuccessStatusCode)
|
||||
{
|
||||
var body = await resp.Content.ReadAsStringAsync(ct);
|
||||
throw new InvalidOperationException($"更新 JSON 失败 HTTP {(int)resp.StatusCode}: {body}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>简易限速流:按字节/秒节流读取。</summary>
|
||||
private sealed class ThrottledStream : Stream
|
||||
{
|
||||
private readonly Stream _inner;
|
||||
private readonly long _bytesPerSecond;
|
||||
private long _windowBytes;
|
||||
private long _windowStart = Environment.TickCount64;
|
||||
|
||||
public ThrottledStream(Stream inner, long bytesPerSecond)
|
||||
{
|
||||
_inner = inner;
|
||||
_bytesPerSecond = Math.Max(1024, bytesPerSecond);
|
||||
}
|
||||
|
||||
public override bool CanRead => _inner.CanRead;
|
||||
public override bool CanSeek => false;
|
||||
public override bool CanWrite => false;
|
||||
public override long Length => _inner.Length;
|
||||
public override long Position { get => _inner.Position; set => throw new NotSupportedException(); }
|
||||
public override void Flush() => _inner.Flush();
|
||||
public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException();
|
||||
public override void SetLength(long value) => throw new NotSupportedException();
|
||||
public override void Write(byte[] buffer, int offset, int count) => throw new NotSupportedException();
|
||||
|
||||
public override int Read(byte[] buffer, int offset, int count)
|
||||
{
|
||||
var n = _inner.Read(buffer, offset, count);
|
||||
if (n > 0) Throttle(n);
|
||||
return n;
|
||||
}
|
||||
|
||||
public override async Task<int> ReadAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken)
|
||||
{
|
||||
var n = await _inner.ReadAsync(buffer.AsMemory(offset, count), cancellationToken);
|
||||
if (n > 0) await ThrottleAsync(n, cancellationToken);
|
||||
return n;
|
||||
}
|
||||
|
||||
private void Throttle(int n)
|
||||
{
|
||||
_windowBytes += n;
|
||||
var elapsed = Environment.TickCount64 - _windowStart;
|
||||
if (elapsed < 1) elapsed = 1;
|
||||
var allowed = _bytesPerSecond * elapsed / 1000;
|
||||
if (_windowBytes > allowed)
|
||||
{
|
||||
var wait = (int)((_windowBytes - allowed) * 1000 / _bytesPerSecond);
|
||||
if (wait > 0) Thread.Sleep(Math.Min(wait, 2000));
|
||||
}
|
||||
if (elapsed >= 1000)
|
||||
{
|
||||
_windowBytes = 0;
|
||||
_windowStart = Environment.TickCount64;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task ThrottleAsync(int n, CancellationToken ct)
|
||||
{
|
||||
_windowBytes += n;
|
||||
var elapsed = Environment.TickCount64 - _windowStart;
|
||||
if (elapsed < 1) elapsed = 1;
|
||||
var allowed = _bytesPerSecond * elapsed / 1000;
|
||||
if (_windowBytes > allowed)
|
||||
{
|
||||
var wait = (int)((_windowBytes - allowed) * 1000 / _bytesPerSecond);
|
||||
if (wait > 0) await Task.Delay(Math.Min(wait, 2000), ct);
|
||||
}
|
||||
if (elapsed >= 1000)
|
||||
{
|
||||
_windowBytes = 0;
|
||||
_windowStart = Environment.TickCount64;
|
||||
}
|
||||
}
|
||||
|
||||
protected override void Dispose(bool disposing)
|
||||
{
|
||||
// 不释放 inner(由调用方 using FileStream)
|
||||
base.Dispose(disposing);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user