Files
zhaowei.huangandCursor 4223a572c5 新增 OTA WatchDog 编排与车队健康/报警后端。
覆盖包库回传、任务下发、CDM 任务同步与报警采集,并为包/任务 ID 与上传文件名加上路径安全校验。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-26 11:31:51 +08:00

290 lines
12 KiB
C#
Raw Permalink 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.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);
}
}
}