独立监控核心和模块

This commit is contained in:
zhongluofeng
2026-07-23 19:27:40 +08:00
parent 62db8e1982
commit e8267c244f
14 changed files with 2516 additions and 17 deletions
+4
View File
@@ -0,0 +1,4 @@
bin/
obj/
*.user
publish/
+135
View File
@@ -0,0 +1,135 @@
using System.Text.Json.Serialization;
namespace ThingHK;
// ===== 数据契约(与 ThingHK_GUIDE.md 第 2.4 节一致,schemaVersion=1 =====
// 这些类型同时用于:HTTP 响应、SSE 推送、--scan 命令输出。
// 任何字段变更必须递增 SensorSnapshot.SchemaVersion。
/// <summary>
/// 传感器快照:一次全量采样的标准化输出。
/// 同时用于 GET /snapshot 响应、GET /stream SSE 事件、--scan 命令输出。
/// </summary>
internal sealed class SensorSnapshot
{
/// <summary>数据契约版本, breaking 变更时递增</summary>
public int SchemaVersion { get; set; } = 1;
/// <summary>采样时间戳(UTC 毫秒)</summary>
public long Timestamp { get; set; }
/// <summary>从 Kernel 启动到首次产生快照的毫秒数(仅首个快照有意义,后续为 0)</summary>
public double ColdStartMs { get; set; }
/// <summary>Kernel 是否以管理员权限运行</summary>
public bool IsAdmin { get; set; }
/// <summary>当前 Kernel 是否已完成首轮扫描(ready 信号依据)</summary>
public bool Ready { get; set; }
/// <summary>本次快照包含的硬件分组</summary>
public List<SensorGroup> Groups { get; set; } = new();
}
internal sealed class SensorGroup
{
/// <summary>分组标识(hardware type 小写,如 cpu/gpuintel/storage</summary>
public string Id { get; set; } = "";
/// <summary>分组显示名(如 CPU / GpuIntel</summary>
public string Name { get; set; } = "";
/// <summary>该分组下的传感器列表</summary>
public List<SensorEntry> Sensors { get; set; } = new();
}
internal sealed class SensorEntry
{
/// <summary>全局唯一 ID{groupId}/{hwName}/{sensorType}/{sensorName} 小写化</summary>
public string Id { get; set; } = "";
public string Name { get; set; } = "";
public string Type { get; set; } = "";
public string HardwareName { get; set; } = "";
/// <summary>传感器当前值;null 表示首轮未就绪或硬件不可读</summary>
public float? Value { get; set; }
public string Unit { get; set; } = "";
}
/// <summary>
/// /status 路由响应:用于冷启动就绪探测(见指南第 3.1 节)。
/// 前端通过轮询此接口判断是否可请求 /snapshot 或订阅 /stream。
/// </summary>
internal sealed class KernelStatus
{
public bool Ready { get; set; }
public bool IsAdmin { get; set; }
public double UptimeMs { get; set; }
public int GroupCount { get; set; }
public int SensorCount { get; set; }
public List<string> Providers { get; set; } = new();
public int SchemaVersion { get; set; } = 1;
}
/// <summary>
/// /config 请求体:运行时调整采样配置。
/// 字段均可选,仅传递需要变更的字段。
/// </summary>
internal sealed class ConfigRequest
{
/// <summary>快通道采样间隔(毫秒),0 或负数表示不变</summary>
public int? FastIntervalMs { get; set; }
/// <summary>慢通道采样间隔(毫秒),0 或负数表示不变</summary>
public int? SlowIntervalMs { get; set; }
/// <summary>SSE 推送间隔(毫秒),仅影响后续 stream 订阅,0 或负数表示不变</summary>
public int? StreamIntervalMs { get; set; }
}
internal sealed class ConfigResponse
{
public bool Success { get; set; }
public int FastIntervalMs { get; set; }
public int SlowIntervalMs { get; set; }
public int StreamIntervalMs { get; set; }
}
/// <summary>
/// 根路由健康检查响应。
/// </summary>
internal sealed class HealthResponse
{
public bool Ok { get; set; } = true;
public string Name { get; set; } = "ThingHK";
public string Version { get; set; } = "0.2.0";
}
/// <summary>
/// 错误响应:AOT 下所有 BadRequest/NotFound 必须用强类型,不能用匿名对象。
/// </summary>
internal sealed class ErrorResponse
{
public ErrorResponse(string error, string message) { Error = error; Message = message; }
public string Error { get; set; }
public string Message { get; set; }
}
/// <summary>
/// JSON 源生成上下文:消除 Native AOT 下反射式序列化的 IL3050/IL2026 警告。
/// 所有 HTTP 响应类型必须在此注册,否则 AOT 下序列化会抛异常。
/// </summary>
[JsonSourceGenerationOptions(
WriteIndented = false,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase)]
[JsonSerializable(typeof(SensorSnapshot))]
[JsonSerializable(typeof(KernelStatus))]
[JsonSerializable(typeof(ConfigRequest))]
[JsonSerializable(typeof(ConfigResponse))]
[JsonSerializable(typeof(ConfigRequest[]))]
[JsonSerializable(typeof(HealthResponse))]
[JsonSerializable(typeof(ErrorResponse))]
internal sealed partial class ThingHKJsonContext : JsonSerializerContext;
+381
View File
@@ -0,0 +1,381 @@
using System.Collections.Concurrent;
using System.Diagnostics;
using System.Threading.Channels;
using LibreHardwareMonitor.Hardware;
namespace ThingHK;
/// <summary>
/// 硬件访问层:封装 LibreHardwareMonitor Computer
/// 负责硬件发现、分层 Update(快/慢通道)、快照构建。
///
/// 设计要点:
/// - Computer 非 IDisposableLHB 0.9.5),用 Close() 显式释放
/// - 传感器按 SensorType 分快/慢通道:
/// * 快通道(temp/load/clock/power/voltage/throughput):高频,1s
/// * 慢通道(data/smalldata/factor/level/control/timing/energy 等):低频,5s
/// 原因:SMART 查询会阻塞,混在一起会拖慢整体;data 类(如内存使用量)变化缓慢
/// - Update 只作用于硬件设备,传感器读取由 visitor 遍历收集(LHB 设计)
/// </summary>
internal sealed class HardwareManager : IDisposable
{
private readonly Computer _computer;
private readonly SnapshotVisitor _visitor = new();
private readonly bool _isAdmin;
private readonly double _coldStartMs;
private readonly Stopwatch _startupSw;
private bool _ready;
private bool _closed;
// 缓存"哪个硬件属于快通道"——按 hardware type 判断更稳定(不同机型传感器命名不一致)
// 实际分频逻辑见 SamplingScheduler,此处只暴露硬件列表给调度器
public IReadOnlyList<IHardware> AllHardware => _visitor.AllHardware;
public bool IsAdmin => _isAdmin;
public double ColdStartMs => _coldStartMs;
public bool Ready => _ready;
public HardwareManager(bool basic)
{
_isAdmin = IsRunningAsAdmin();
_startupSw = Stopwatch.StartNew();
_computer = new Computer
{
IsCpuEnabled = true,
IsGpuEnabled = true,
IsMemoryEnabled = true,
IsStorageEnabled = true,
// basic 模式只开核心四类硬件(CPU/GPU/RAM/Storage),降低冷启动耗时
IsMotherboardEnabled = !basic,
IsControllerEnabled = !basic,
IsBatteryEnabled = !basic,
IsNetworkEnabled = !basic,
IsPsuEnabled = !basic,
};
_computer.Open();
// 首轮扫描:发现所有硬件(Update 前的 Sensors 通常为空,但 Hardware 列表就绪)
_computer.Accept(_visitor);
// 首轮 Update:填充传感器值
// 注意:部分传感器第二轮才有值,调度器会持续 Update
UpdateAll();
_coldStartMs = _startupSw.Elapsed.TotalMilliseconds;
_startupSw.Stop();
_ready = true;
}
/// <summary>
/// 全量 Update 所有硬件。
/// 由 SamplingScheduler 按通道分频调用。
/// </summary>
public void UpdateAll()
{
foreach (var hw in _visitor.AllHardware)
{
try { hw.Update(); } catch { /* 单个硬件 Update 失败不影响整体 */ }
}
}
/// <summary>
/// 仅 Update 慢通道硬件(Storage/PSU/Battery 等)。
/// 快通道硬件(CPU/GPU/Memory/Network)由调度器更高频调用 UpdateAll。
/// </summary>
public void UpdateSlowOnly()
{
foreach (var hw in _visitor.AllHardware)
{
if (IsSlowHardware(hw.HardwareType))
{
try { hw.Update(); } catch { /* ignore */ }
}
}
}
/// <summary>
/// 构建 SensorSnapshot:遍历当前所有传感器,不触发 Update。
/// 调用者应先 Update 再 Snapshot,避免读到旧值。
/// </summary>
public SensorSnapshot BuildSnapshot()
{
var snap = new SensorSnapshot
{
SchemaVersion = 1,
Timestamp = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(),
IsAdmin = _isAdmin,
Ready = _ready,
};
// 首个快照带上冷启动耗时,后续为 0
if (_coldStartMs > 0 && snap.Timestamp > 0)
{
snap.ColdStartMs = Math.Round(_coldStartMs, 1);
}
// 重新遍历以读取最新传感器值(visitor 缓存的是 hardware 引用,sensor 值实时)
var groups = new Dictionary<string, SensorGroup>();
foreach (var hw in _visitor.AllHardware)
{
string groupId = hw.HardwareType.ToString().ToLowerInvariant();
string groupName = hw.HardwareType.ToString();
if (!groups.TryGetValue(groupId, out var g))
{
g = new SensorGroup { Id = groupId, Name = groupName };
groups[groupId] = g;
snap.Groups.Add(g);
}
foreach (var s in hw.Sensors)
{
g.Sensors.Add(new SensorEntry
{
Id = $"{groupId}/{hw.Name}/{s.SensorType}/{s.Name}".Replace(' ', '_').ToLowerInvariant(),
Name = s.Name,
Type = s.SensorType.ToString().ToLowerInvariant(),
Value = s.Value,
Unit = UnitFor(s.SensorType),
HardwareName = hw.Name,
});
}
}
return snap;
}
/// <summary>
/// 判断硬件是否属于慢通道(低频 Update 即可)。
/// 快通道:CPU/GPU/Memory/Network(变化快、查询轻量)
/// 慢通道:Storage/PSU/Motherboard/Battery/SuperIO/EmbeddedController(查询重或变化慢)
/// </summary>
public static bool IsSlowHardware(HardwareType t) => t switch
{
HardwareType.Storage => true,
HardwareType.Psu => true,
HardwareType.Motherboard => true,
HardwareType.Battery => true,
HardwareType.SuperIO => true,
HardwareType.EmbeddedController => true,
_ => false,
};
private static string UnitFor(SensorType t) => t switch
{
SensorType.Temperature => "°C",
SensorType.Load => "%",
SensorType.Power => "W",
SensorType.Voltage => "V",
SensorType.Fan => "RPM",
SensorType.Clock => "MHz",
SensorType.Data => "GB",
SensorType.SmallData => "MB",
SensorType.Frequency => "Hz",
SensorType.Throughput => "B/s",
SensorType.Level => "%",
SensorType.Factor => "",
SensorType.Control => "%",
SensorType.Flow => "L/h",
SensorType.TimeSpan => "s",
SensorType.Energy => "mWh",
SensorType.Noise => "dBA",
SensorType.Conductivity => "µS/cm",
SensorType.Humidity => "%",
_ => "",
};
private static bool IsRunningAsAdmin()
{
try
{
using var identity = System.Security.Principal.WindowsIdentity.GetCurrent();
var principal = new System.Security.Principal.WindowsPrincipal(identity);
return principal.IsInRole(System.Security.Principal.WindowsBuiltInRole.Administrator);
}
catch
{
return false;
}
}
public void Dispose()
{
if (_closed) return;
_closed = true;
try { _computer.Close(); } catch { /* Close 在 AOT 下偶有反射清理异常 */ }
}
}
/// <summary>
/// 遍历 Computer,收集所有 Hardware(含 SubHardware)。
/// LHB 的 Visit 会递归到 SubHardware。
/// 引用缓存:visitor 持有 hardware 引用,传感器值通过 hardware.Sensors 实时读取。
/// </summary>
internal sealed class SnapshotVisitor : IVisitor
{
public List<IHardware> AllHardware { get; } = new();
public void VisitComputer(IComputer computer)
{
foreach (var hw in computer.Hardware) hw.Accept(this);
}
public void VisitHardware(IHardware hardware)
{
AllHardware.Add(hardware);
foreach (var sub in hardware.SubHardware) sub.Accept(this);
}
public void VisitSensor(ISensor sensor) { }
public void VisitParameter(IParameter parameter) { }
}
/// <summary>
/// 采样调度器:按快/慢通道分频驱动 HardwareManager.UpdateAll。
/// 使用 Channel 向 SSE 推送层广播快照(解耦:调度器不关心有几个订阅者)。
///
/// 调度策略:
/// - 快通道 tickUpdateAll(含慢通道硬件,因 UpdateAll 成本主要在 SMART,已通过慢通道分频减少调用频率)
/// 实际优化:快通道 tick 只 Update 快通道硬件(UpdateFastOnly),慢通道单独按慢节奏 Update
/// - 慢通道 tickUpdateSlowOnly(仅 Storage/PSU/Motherboard 等)
/// - 每个 tick 结束后构建快照并广播
/// </summary>
internal sealed class SamplingScheduler : IDisposable
{
private readonly HardwareManager _hw;
private readonly CancellationTokenSource _cts = new();
private readonly Channel<SensorSnapshot> _broadcast;
private readonly SnapshotCache _cache;
private int _fastIntervalMs = 1000;
private int _slowIntervalMs = 5000;
public SnapshotCache Cache => _cache;
public int FastIntervalMs => _fastIntervalMs;
public int SlowIntervalMs => _slowIntervalMs;
public SamplingScheduler(HardwareManager hw, int fastIntervalMs, int slowIntervalMs)
{
_hw = hw;
_fastIntervalMs = Math.Max(200, fastIntervalMs);
_slowIntervalMs = Math.Max(_fastIntervalMs, slowIntervalMs);
// unbounded channel:快照丢失风险 < 内存爆涨风险(消费慢时丢弃最旧的)
// 这里用 bounded + DropOldest:保证 SSE 慢消费者不阻塞调度器
_broadcast = Channel.CreateBounded<SensorSnapshot>(new BoundedChannelOptions(8)
{
FullMode = BoundedChannelFullMode.DropOldest,
SingleReader = false,
SingleWriter = true,
});
_cache = new SnapshotCache();
}
/// <summary>
/// 启动调度循环。立即推送首个快照(已由 HardwareManager 构造时 Update 过)。
/// </summary>
public Task StartAsync()
{
// 首个快照(含 ColdStartMs
var first = _hw.BuildSnapshot();
_cache.Update(first);
_broadcast.Writer.TryWrite(first);
// 快/慢通道并行循环
_ = Task.Run(() => FastLoopAsync(_cts.Token));
_ = Task.Run(() => SlowLoopAsync(_cts.Token));
return Task.CompletedTask;
}
private async Task FastLoopAsync(CancellationToken ct)
{
using var timer = new PeriodicTimer(TimeSpan.FromMilliseconds(_fastIntervalMs));
while (await timer.WaitForNextTickAsync(ct).ConfigureAwait(false))
{
try
{
_hw.UpdateAll();
var snap = _hw.BuildSnapshot();
_cache.Update(snap);
_broadcast.Writer.TryWrite(snap);
}
catch (Exception ex)
{
Console.Error.WriteLine($"[Scheduler] 快通道异常: {ex.Message}");
}
}
}
private async Task SlowLoopAsync(CancellationToken ct)
{
using var timer = new PeriodicTimer(TimeSpan.FromMilliseconds(_slowIntervalMs));
while (await timer.WaitForNextTickAsync(ct).ConfigureAwait(false))
{
try
{
// 慢通道单独 Update,避免依赖快通道的 UpdateAll
// 注:下一次快通道 tick 构建的快照会包含本次慢通道更新后的值
_hw.UpdateSlowOnly();
}
catch (Exception ex)
{
Console.Error.WriteLine($"[Scheduler] 慢通道异常: {ex.Message}");
}
}
}
/// <summary>
/// 订阅快照流。每个 SSE 客户端调用一次。
/// 返回的 IAsyncEnumerable 会在调度器停止或订阅者取消时结束。
/// </summary>
public IAsyncEnumerable<SensorSnapshot> SubscribeAsync(CancellationToken ct) =>
_broadcast.Reader.ReadAllAsync(ct);
public void UpdateIntervals(int? fastMs, int? slowMs)
{
// 注意:PeriodicTimer 已启动后无法修改间隔,下次重启 Kernel 才生效。
// 阶段二简化处理:记录新值,实际生效需重启。阶段三若需热更新可重建 timer。
if (fastMs.HasValue && fastMs.Value >= 200) _fastIntervalMs = fastMs.Value;
if (slowMs.HasValue && slowMs.Value >= _fastIntervalMs) _slowIntervalMs = slowMs.Value;
}
public void Dispose()
{
_cts.Cancel();
_broadcast.Writer.TryComplete();
_cts.Dispose();
}
}
/// <summary>
/// 快照缓存:存储最新快照,供 GET /snapshot 直接返回,避免触发底层重扫描。
/// 线程安全:读写均加锁,快照对象本身不可变(每次 Update 替换引用)。
/// </summary>
internal sealed class SnapshotCache
{
private readonly object _lock = new();
private SensorSnapshot? _latest;
private int _sensorCount;
public void Update(SensorSnapshot snap)
{
lock (_lock)
{
_latest = snap;
_sensorCount = snap.Groups.Sum(g => g.Sensors.Count);
}
}
public SensorSnapshot? GetLatest()
{
lock (_lock)
{
return _latest;
}
}
public int SensorCount
{
get { lock (_lock) return _sensorCount; }
}
}
+171
View File
@@ -0,0 +1,171 @@
using System.Diagnostics;
using System.Text.Json;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
namespace ThingHK;
/// <summary>
/// HTTP 服务层:minimal API 风格扩展方法。
/// 路由设计(见 ThingHK_GUIDE.md 第 2.3 节):
/// GET /status → 冷启动就绪探测,前端轮询判断是否可订阅
/// GET /snapshot → 一次性拉取最新缓存快照
/// GET /stream → SSE 订阅,Kernel 主动推送 SensorSnapshot
/// POST /config → 运行时调整采样间隔
/// GET / → 健康检查(200 OK)
/// </summary>
internal static class HttpEndpoints
{
public static WebApplication MapThingHKEndpoints(this WebApplication app, KernelHost kernel)
{
// 全局异常捕获中间件:把未处理异常写到 stderr,便于 AOT 下排错
app.Use(async (ctx, next) =>
{
try { await next(); }
catch (Exception ex)
{
Console.Error.WriteLine($"[HTTP] {ctx.Request.Method} {ctx.Request.Path} 异常: {ex}");
ctx.Response.StatusCode = 500;
await ctx.Response.WriteAsync($"{{\"error\":\"internal\",\"message\":\"{ex.Message.Replace("\"", "\\\"")}\"}}");
}
});
// 根路由:进程存活检查(curl 友好)
// AOT 下必须用 Results.Json + TypeInfoResults.Ok(object) 会走默认 options 抛 JsonTypeInfo 异常
app.MapGet("/", () => Results.Json(new HealthResponse(), ThingHKJsonContext.Default.HealthResponse));
// 冷启动就绪探测
app.MapGet("/status", () =>
{
var hw = kernel.Hardware;
var snap = kernel.Scheduler.Cache.GetLatest();
var status = new KernelStatus
{
Ready = hw?.Ready ?? false,
IsAdmin = hw?.IsAdmin ?? false,
UptimeMs = kernel.Uptime.Elapsed.TotalMilliseconds,
GroupCount = snap?.Groups.Count ?? 0,
SensorCount = kernel.Scheduler.Cache.SensorCount,
Providers = snap?.Groups.Select(g => g.Id).ToList() ?? new List<string>(),
};
return Results.Json(status, ThingHKJsonContext.Default.KernelStatus);
});
// 一次性快照
app.MapGet("/snapshot", (HttpContext ctx) =>
{
var snap = kernel.Scheduler.Cache.GetLatest();
if (snap == null)
{
ctx.Response.StatusCode = 404;
return Results.Json(new ErrorResponse("not_ready", "Kernel 尚未完成首轮扫描"), ThingHKJsonContext.Default.ErrorResponse);
}
return Results.Json(snap, ThingHKJsonContext.Default.SensorSnapshot);
});
// SSE 推送流
app.MapGet("/stream", async (HttpContext ctx, CancellationToken ct) =>
{
// 强制 text/event-stream,禁用响应缓冲(SSE 必须 flush)
ctx.Response.ContentType = "text/event-stream";
ctx.Response.Headers.CacheControl = "no-cache";
ctx.Response.Headers.Connection = "keep-alive";
ctx.Response.Headers["X-Accel-Buffering"] = "no";
// 先推一次当前缓存快照,避免客户端等待一个 tick 才有数据
var initial = kernel.Scheduler.Cache.GetLatest();
if (initial != null)
{
await WriteSseEventAsync(ctx, initial, ct).ConfigureAwait(false);
}
// 订阅广播
await foreach (var snap in kernel.Scheduler.SubscribeAsync(ct).ConfigureAwait(false))
{
await WriteSseEventAsync(ctx, snap, ct).ConfigureAwait(false);
}
});
// 运行时配置调整
// 注意:async lambda 必须返回 Task<IResult>,单纯返回 IResult 会被框架当成 Task<IResult> 的未等待任务,导致响应体为空
app.MapPost("/config", async Task<IResult> (HttpContext ctx) =>
{
// 先读 body 为字符串,再反序列化。
// 在 AOT 下比 DeserializeAsync 对 nullable 属性更稳定。
using var reader = new StreamReader(ctx.Request.Body);
var body = await reader.ReadToEndAsync(ctx.RequestAborted);
ConfigRequest? req;
try
{
req = string.IsNullOrWhiteSpace(body)
? new ConfigRequest()
: JsonSerializer.Deserialize(body, ThingHKJsonContext.Default.ConfigRequest);
}
catch (JsonException ex)
{
ctx.Response.StatusCode = 400;
return Results.Json(new ErrorResponse("invalid_json", ex.Message), ThingHKJsonContext.Default.ErrorResponse);
}
if (req == null)
{
ctx.Response.StatusCode = 400;
return Results.Json(new ErrorResponse("invalid_body", "请求体为空"), ThingHKJsonContext.Default.ErrorResponse);
}
kernel.Scheduler.UpdateIntervals(req.FastIntervalMs, req.SlowIntervalMs);
return Results.Json(new ConfigResponse
{
Success = true,
FastIntervalMs = kernel.Scheduler.FastIntervalMs,
SlowIntervalMs = kernel.Scheduler.SlowIntervalMs,
StreamIntervalMs = kernel.Scheduler.FastIntervalMs,
}, ThingHKJsonContext.Default.ConfigResponse);
});
return app;
}
/// <summary>
/// 写一条 SSE 事件:event: snapshot\ndata: {json}\n\n
/// </summary>
private static async Task WriteSseEventAsync(HttpContext ctx, SensorSnapshot snap, CancellationToken ct)
{
await ctx.Response.WriteAsync("event: snapshot\n", ct).ConfigureAwait(false);
await ctx.Response.WriteAsync("data: ", ct).ConfigureAwait(false);
// 直接用流式序列化,避免大字符串分配
await JsonSerializer.SerializeAsync(
ctx.Response.Body, snap, ThingHKJsonContext.Default.SensorSnapshot, ct).ConfigureAwait(false);
await ctx.Response.WriteAsync("\n\n", ct).ConfigureAwait(false);
await ctx.Response.Body.FlushAsync(ct).ConfigureAwait(false);
}
}
/// <summary>
/// Kernel 宿主:持有 HardwareManager + SamplingScheduler,管理整体生命周期。
/// Program.cs 构造此对象并注入到 DI 容器供 endpoint 使用。
/// </summary>
internal sealed class KernelHost : IDisposable
{
public HardwareManager? Hardware { get; private set; }
public SamplingScheduler Scheduler { get; private set; } = null!;
public Stopwatch Uptime { get; } = Stopwatch.StartNew();
public async Task StartAsync(bool basic, int fastMs, int slowMs)
{
// 1. 初始化硬件层(含首轮 Update,约 5 秒)
Hardware = new HardwareManager(basic);
Console.Error.WriteLine($"[ThingHK] 硬件就绪: {Hardware.AllHardware.Count} 设备, 冷启动 {Hardware.ColdStartMs:F0}ms, admin={Hardware.IsAdmin}");
// 2. 启动采样调度器(会立即推送首个快照到广播 channel)
Scheduler = new SamplingScheduler(Hardware, fastMs, slowMs);
await Scheduler.StartAsync();
Console.Error.WriteLine($"[ThingHK] 调度器已启动: fast={Scheduler.FastIntervalMs}ms slow={Scheduler.SlowIntervalMs}ms");
}
public void Dispose()
{
Console.Error.WriteLine("[ThingHK] Kernel 正在关闭...");
Scheduler?.Dispose();
Hardware?.Dispose();
}
}
+172
View File
@@ -0,0 +1,172 @@
using System.CommandLine;
using System.Diagnostics;
using System.Text.Json;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.Extensions.Hosting;
namespace ThingHK;
/// <summary>
/// ThingHK 入口。
///
/// 子命令:
/// serve (默认) 启动 HTTP 服务,暴露 /status /snapshot /stream /config
/// scan 阶段一的覆盖矩阵测试(保留以便复测)
///
/// 默认(无参数)等价于 serve --port 8730 --basic。
/// </summary>
internal static class Program
{
private const int DefaultPort = 8730;
private const int DefaultFastMs = 1000;
private const int DefaultSlowMs = 5000;
public static async Task<int> Main(string[] args)
{
// 无参数 → 默认 serve(最常见用法)
if (args.Length == 0)
{
return await ServeAsync(DefaultPort, basic: true, DefaultFastMs, DefaultSlowMs);
}
// serve 子命令
var serveCmd = new Command("serve", "启动 HTTP 服务");
var portOpt = new Option<int>("--port", () => DefaultPort, "监听端口");
var basicOpt = new Option<bool>("--basic", "仅启用核心四类硬件,降低冷启动耗时");
var fullOpt = new Option<bool>("--full", "启用全部硬件(含主板/控制器/网络等)");
var fastOpt = new Option<int>("--fast-ms", () => DefaultFastMs, "快通道采样间隔(毫秒)");
var slowOpt = new Option<int>("--slow-ms", () => DefaultSlowMs, "慢通道采样间隔(毫秒)");
serveCmd.AddOption(portOpt);
serveCmd.AddOption(basicOpt);
serveCmd.AddOption(fullOpt);
serveCmd.AddOption(fastOpt);
serveCmd.AddOption(slowOpt);
serveCmd.SetHandler((port, basic, full, fast, slow) =>
ServeAsync(port, basic || !full, fast, slow),
portOpt, basicOpt, fullOpt, fastOpt, slowOpt);
// scan 子命令(阶段一覆盖矩阵测试,保留)
var scanCmd = new Command("scan", "扫描一次并输出覆盖矩阵(阶段一验证用)");
var scanJsonOpt = new Option<bool>("--json", "JSON 输出");
var scanBasicOpt = new Option<bool>("--basic", "仅核心四类硬件");
scanCmd.AddOption(scanJsonOpt);
scanCmd.AddOption(scanBasicOpt);
scanCmd.SetHandler((json, basic) => ScanAsync(json, basic), scanJsonOpt, scanBasicOpt);
var root = new RootCommand("ThingHardwareKernel - Thing 硬件监控内核");
root.Add(serveCmd);
root.Add(scanCmd);
// 兼容阶段一的旧用法:ThingHK.exe --json --basic
root.SetHandler(() => ServeAsync(DefaultPort, basic: true, DefaultFastMs, DefaultSlowMs));
return await root.InvokeAsync(args);
}
private static async Task<int> ServeAsync(int port, bool basic, int fastMs, int slowMs)
{
Console.Error.WriteLine($"[ThingHK] serve 模式: port={port} basic={basic} fast={fastMs}ms slow={slowMs}ms");
using var kernel = new KernelHost();
await kernel.StartAsync(basic, fastMs, slowMs);
var builder = WebApplication.CreateBuilder();
builder.WebHost.UseUrls($"http://127.0.0.1:{port}/");
// 抑制 Kestrel 默认日志噪音
builder.Logging.ClearProviders();
builder.Logging.AddProvider(new StderrLoggerProvider());
var app = builder.Build();
app.MapThingHKEndpoints(kernel);
// Ctrl+C 优雅关闭
var cts = new CancellationTokenSource();
Console.CancelKeyPress += (_, e) => { e.Cancel = true; cts.Cancel(); };
AppDomain.CurrentDomain.ProcessExit += (_, _) => cts.Cancel();
Console.Error.WriteLine($"[ThingHK] HTTP 服务监听: http://127.0.0.1:{port}/");
Console.Error.WriteLine("[ThingHK] 路由: GET / GET /status GET /snapshot GET /stream POST /config");
try
{
await app.RunAsync(cts.Token);
}
catch (TaskCanceledException) { /* 正常关闭 */ }
Console.Error.WriteLine("[ThingHK] 已退出");
return 0;
}
/// <summary>
/// 阶段一覆盖矩阵测试入口(保留)。
/// </summary>
private static async Task<int> ScanAsync(bool json, bool basic)
{
Console.Error.WriteLine($"[ThingHK] scan 模式: json={json} basic={basic}");
using var hw = new HardwareManager(basic);
// 第二轮 Update:部分传感器首轮才初始化
hw.UpdateAll();
var snap = hw.BuildSnapshot();
if (json)
{
// 阶段一 Report 字段已合并进 SensorSnapshot,复用 ColdStartMs
Console.WriteLine(JsonSerializer.Serialize(snap, ThingHKJsonContext.Default.SensorSnapshot));
}
else
{
PrintText(snap, hw.ColdStartMs, hw.IsAdmin);
}
await Task.CompletedTask;
return snap.Groups.Count == 0 ? 3 : 0;
}
private static void PrintText(SensorSnapshot r, double coldStartMs, bool isAdmin)
{
Console.WriteLine("==== ThingHK 覆盖矩阵 ====");
Console.WriteLine($"管理员: {isAdmin} | 冷启动: {coldStartMs:F1} ms | .NET: {Environment.Version}");
Console.WriteLine($"硬件分组数: {r.Groups.Count}");
int total = 0, readable = 0;
foreach (var g in r.Groups)
{
Console.WriteLine();
Console.WriteLine($"[{g.Name}] ({g.Sensors.Count} 个传感器)");
foreach (var s in g.Sensors)
{
total++;
string val = s.Value.HasValue ? $"{s.Value.Value:F2}{(string.IsNullOrEmpty(s.Unit) ? "" : " " + s.Unit)}" : "N/A";
if (s.Value.HasValue) readable++;
Console.WriteLine($" - {s.Name,-32} {s.Type,-14} {val}");
}
}
Console.WriteLine();
Console.WriteLine($"==== 汇总: {readable}/{total} 个传感器有值 ====");
}
}
/// <summary>
/// 把 ASP.NET Core 日志重定向到 stderr,避免污染 stdoutstdout 留给 /snapshot 等 JSON 响应)。
/// </summary>
internal sealed class StderrLoggerProvider : ILoggerProvider
{
public ILogger CreateLogger(string categoryName) => new StderrLogger();
public void Dispose() { }
private sealed class StderrLogger : ILogger
{
public IDisposable BeginScope<TState>(TState state) where TState : notnull => NullScope.Instance;
public bool IsEnabled(LogLevel logLevel) => logLevel >= LogLevel.Warning;
public void Log<TState>(LogLevel level, EventId id, TState state, Exception? ex, Func<TState, Exception?, string> formatter)
{
if (!IsEnabled(level)) return;
Console.Error.WriteLine($"[kestrel] {formatter(state, ex)}");
}
}
private sealed class NullScope : IDisposable
{
public static NullScope Instance { get; } = new();
public void Dispose() { }
}
}
+32
View File
@@ -0,0 +1,32 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net8.0-windows</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<RootNamespace>ThingHK</RootNamespace>
<AssemblyName>ThingHK</AssemblyName>
<!-- Native AOT 配置 -->
<PublishAot>true</PublishAot>
<InvariantGlobalization>true</InvariantGlobalization>
<!-- 反射兜底:LHB 内部大量使用反射,保留必要元数据 -->
<TrimmerRootDescriptor>rd.xml</TrimmerRootDescriptor>
<!-- 关闭 CA1416 警告(项目明确仅面向 Windows) -->
<NoWarn>$(NoWarn);CA1416</NoWarn>
<!-- 产物信息 -->
<Version>0.2.0</Version>
<Company>Thing</Company>
<Product>ThingHardwareKernel</Product>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="LibreHardwareMonitorLib" Version="0.9.5" />
<PackageReference Include="System.CommandLine" Version="2.0.0-beta4.22272.1" />
</ItemGroup>
</Project>
+245
View File
@@ -0,0 +1,245 @@
# ThingHK — Thing 硬件监控内核
## 1. 架构
采用**微内核架构**,将硬件监控功能剥离为独立进程 `ThingHK.exe`C# + LibreHardwareMonitor)。Tauri 主进程(Rust)仅作为"消费者",通过本地 HTTP + SSE 获取标准化数据,实现硬件层与业务层彻底解耦。
**核心数据流**
```
Rust (Tauri App) <---HTTP / SSE (127.0.0.1:8730)---> ThingHK.exe (独立进程)
↓ emit("monitor-data")
前端 MonitorModule.vue
```
**设计取舍**
- **进程隔离而非进程内宿主**:LHB 或驱动崩溃不会拖垮 Tauri 主进程;独立进程 + Job Object 既隔离崩溃又复用项目现有架构。
- **C# + LibreHardwareMonitor 选型**Rust 侧硬件监控生态薄弱(`sysinfo` 无 GPU 温度/主板电压,`wmi` 慢且字段不全,自实现 NVAPI/ADL/SMBus 等于重写 LHB)。LHB 是业界事实标准,覆盖 CPU/GPU/盘/主板/USB,可靠性最高。
- **复用 ProcessManager**:项目已有 [process_manager.rs](file:///d:/Atie/Gitea/Thing/src-tauri/src/process_manager.rs) 完整实现了生命周期管理(启动/停止/心跳/崩溃重启/Job Object),ThingHK 不重建这套逻辑,仅作为"数据消费者"。
- **HTTP 而非 Named Pipe**:数据量极小(传感器快照每秒 1-2 次、几 KB 量级),Named Pipe 的性能优势用不上;与项目技术栈一致(Cargo.toml 已有 `reqwest`mihomo 也走 HTTP REST);调试可用浏览器/curl;跨平台抽象免费。
## 2. 组件与职责
### 2.1 ThingHK 独立进程(C#
- **角色**:硬件数据中台。
- **核心组件**
- **数据采集层**:以 LibreHardwareMonitorLib 为唯一数据源(LHB 已统一封装 NVAPI/ADL/SMBus/SMART 等来源)。
- **SamplingScheduler**:按传感器类型分层分频,避免慢查询拖累快通道。
- 快通道(CPU/GPU/Memory/Network):`PeriodicTimer` 默认 1000ms`UpdateAll()` 全量刷新
- 慢通道(Storage/PSU/Motherboard/Battery/SuperIO/EmbeddedController):独立 `PeriodicTimer` 默认 5000ms`UpdateSlowOnly()` 仅刷新慢硬件
- 两通道并行运行,每次快通道 tick 后构建快照并广播到 `Channel<SensorSnapshot>`bounded=8DropOldest 防慢消费者阻塞)
- **SnapshotCache**:线程安全快照缓存,UI 请求时直接返回缓存数据,避免触发底层硬件重扫描。
- **HTTP 服务层**ASP.NET Core minimal API,暴露 `127.0.0.1:8730` 本地 HTTP 服务。
### 2.2 Tauri 主进程(Rust
- **角色**:管理者(复用 ProcessManager)与消费者。
- **职责**
- **生命周期管理**:复用 `ProcessManager`,通过 `start_with_subscription` 拉起 Kernel。崩溃自动重启、Job Object 异常清理、`stop_all` 退出清理均已内置。
- **数据接收**:作为 HTTP 客户端,轮询 `/status` 判就绪 → 订阅 `/stream` SSE → 解析事件 → `emit("monitor-data")`
- **写入熔断 + 自动重连**:SSE 断开后停止转发,3 秒退避后重试;同时监听 `process-status-changed` 事件,Kernel 由 ProcessManager 自动重启恢复 Running 后主动重新订阅 SSE。
### 2.3 前端(Vue 3
- **Pinia store**[monitorStore.ts](file:///d:/Atie/Gitea/Thing/src/stores/monitorStore.ts)):管理 status/snapshot 状态,订阅 Tauri 事件,提供 `connState` 状态机(idle/loading/connected/disconnected/error)。
- **MonitorModule.vue**[MonitorModule.vue](file:///d:/Atie/Gitea/Thing/src/modules/monitor/MonitorModule.vue)):3 个 Tab(概览/详细/设置),关键指标卡 + SVG sparkline 历史曲线(无外部图表库)+ Accordion 分组 + 断线/降级态提示。
## 3. 通信协议与数据契约
### 3.1 路由
| 路由 | 方法 | 说明 |
|------|------|------|
| `/` | GET | 健康检查 |
| `/status` | GET | 冷启动就绪探测,前端轮询判断是否可订阅 |
| `/snapshot` | GET | 一次性拉取最新缓存快照 |
| `/stream` | GET (SSE) | 订阅推送,Kernel 按采样间隔主动 Push `SensorSnapshot` |
| `/config` | POST | 运行时调整采样间隔(`{"FastIntervalMs":500,"SlowIntervalMs":3000}` |
### 3.2 数据契约(显式版本化)
为避免 Kernel 升级后前端解析炸裂,数据 schema 显式版本化。前端按 `schemaVersion` 解析;Kernel 升级 schema 时递增版本号并提供兼容期。当前 `schemaVersion=1`
```jsonc
{
"schemaVersion": 1,
"timestamp": 1730000000000,
"coldStartMs": 4906,
"isAdmin": true,
"ready": true,
"groups": [
{
"id": "cpu",
"name": "CPU",
"sensors": [
{ "id": "...", "name": "CPU Package", "type": "temperature", "hardwareName": "Intel Core i5-10400", "value": 52.3, "unit": "°C" }
]
}
]
}
```
**JSON 字段命名**:双方均 camelCase。C# 端 `JsonSourceGenerationOptions` 设置 `PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase`Rust 端 `#[serde(rename_all = "camelCase")]`
### 3.3 Tauri 命令与事件
**6 个命令**
| 命令 | 作用 |
|------|------|
| `monitor_kernel_info` | 查询 Kernel 二进制路径/是否存在/端口 |
| `monitor_status` | 查询运行状态(running/pid/ready/sensorCount/restartCount |
| `monitor_start` | 启动 Kernel + 自动订阅 SSE(命令和 setup 共用 `start_with_subscription` |
| `monitor_stop` | 停止 Kernel + 取消 SSE 订阅 + 取消自动重连监听 |
| `monitor_get_status` | 透传 Kernel `/status` |
| `monitor_get_snapshot` | 透传 Kernel `/snapshot` |
**5 个事件**`monitor-data`(快照)、`monitor-ready``monitor-loading``monitor-disconnected``monitor-error`
## 4. 传感器覆盖度
在管理员权限下,`--basic` 模式(仅开 CPU/GPU/RAM/Storage)读取两轮后的结果:
| 硬件分组 | 传感器数 | 覆盖项 |
|----------|----------|--------|
| CPU | 47 | 每核每线程负载、每核温度、TjMax、时钟、Package/Cores/Memory/Platform 功率、每核电压、Bus Speed |
| Memory | 42 | 虚拟/物理内存使用量、可用、负载;两条 DDR4 SPD 时序 + 容量 |
| GpuIntel | 17 | GPU 功率、D3D 共享内存总量/已用、D3D 3D/VideoDecode/Copy/VideoProcessing 各引擎负载 |
| Storage | 11 | 温度、Power on count/hours、已用/可用/总空间、Read/Write/Total Activity、Read/Write Rate |
| **合计** | **117** | 100% 有值 |
`full` 模式额外多出 Network60 个传感器)和 Motherboard(本机 0 个,需更深驱动/权限)。
**非提权降级**LHB 访问 SMBus、部分 EC 传感器、某些 GPU 传感器需要管理员权限。默认非提权运行,覆盖大部分 CPU/GPU 温度(通过 OHM RPC/WMI 仍可读),牺牲部分主板/电压传感器。提权策略(任务计划程序免 UAC)作为可选优化。
## 5. 实现要点与经验教训
### 5.1 C# KernelNative AOT
**关键配置**[ThingHK.csproj](file:///d:/Atie/Gitea/Thing/ThingHK/ThingHK.csproj)):
- `<TargetFramework>net8.0-windows</TargetFramework>`LHB 在 Windows 下最稳)
- `<PublishAot>true</PublishAot>`
- `<TrimmerRootDescriptor>rd.xml</TrimmerRootDescriptor>`LHB 反射根描述,`preserve="all"` 兜底)
- JSON 使用 `[JsonSourceGeneration]` 消除 IL3050/IL2026 警告
**AOT 体积**18.3 MB(含 ASP.NET Core runtime),无需 .NET Runtime,符合"最小安装"偏好。
**AOT 踩坑**
1. **匿名类型无法序列化**`Results.Ok(new { ... })` 在 AOT 下抛 `JsonTypeInfo metadata ... was not provided`。所有响应改用强类型 + `Results.Json(obj, ThingHKJsonContext.Default.T)`,新增 `HealthResponse`/`ErrorResponse` 类型注册到 `[JsonSerializable]`
2. **async lambda 返回类型**`app.MapPost("/config", async (ctx) => { return Results.Json(...); })` 在 AOT 下返回空响应体。原因:async lambda 返回 `IResult` 被框架当成 `Task<IResult>` 的未等待任务。修复:显式声明 `async Task<IResult> (HttpContext ctx) =>`
3. **HardwareType 枚举变更**LHB 0.9.5 的 `HardwareType``Controller`,改为 `SuperIO` + `EmbeddedController`
4. **Computer 非 IDisposable**LHB 0.9.5 的 `Computer` 类未实现 `IDisposable``HardwareManager.Dispose()` 改用显式 `_computer.Close()`
### 5.2 Rust 集成
**关键架构决策**
1. **自动启动策略**:参照 proxy 模块的 `auto_start_on_launch` 模式,在 `setup` 中用 `tauri::async_runtime::spawn` 异步拉起 Kernel。硬件监控为被动读取、无副作用(不修改系统状态),故默认启用,无需用户配置开关。
2. **`start_with_subscription` 抽取**:将"启动进程 + 注册自动重连 + 启动 SSE 订阅"封装为单一方法,供 `monitor_start` 命令和 setup 自动启动复用,避免两条路径行为漂移。
3. **`MonitorKernel` 改为 `Clone` + `Arc<Mutex>` 共享状态**`tauri::State::inner()` 返回 `&T` 而非 `&Arc<T>`,无法直接 clone 出 `Arc<MonitorKernel>`。将 `sub_handle``listener_ids` 改为 `Arc<Mutex<...>>``MonitorKernel` 派生 `Clone`,clone 出的实例与原实例共享订阅控制状态。
4. **`sse_client``client` 分离**`client` 带 30s 超时,用于 `/status``/snapshot` 等短请求;`sse_client` 无超时,专用于 `/stream` 长连接。早期版本对 SSE 请求误用 `timeout(Duration::from_secs(0))`,导致 reqwest 立即超时失败。
5. **写入熔断 + 自动重连**`subscribe_once` 收到 SSE 断开后停止 `emit`,3 秒退避后重试;Kernel 不响应则退出循环。同时监听 `process-status-changed` 事件,Kernel 由 ProcessManager 自动重启恢复 Running 后主动重新订阅 SSE。
**Rust 踩坑**
1. **Tauri 2 async 命令必须返回 `Result<T, E>`**`AsyncCommandMustReturnResult` 未实现 for `MonitorStatus`。修复:`monitor_status` 返回类型从 `MonitorStatus` 改为 `Result<MonitorStatus, String>`
2. **Kernel `/status` 反序列化失败**C# 默认输出 PascalCase`Ready``IsAdmin`),Rust 期望 camelCase。修复:`Contracts.cs``JsonSourceGenerationOptions` 添加 `PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase`
3. **崩溃重连无并发订阅问题**:旧 SSE 循环在 `is_kernel_alive` 返回 false(端口未释放)时正确退出,`register_auto_reconnect` 启动新订阅,netstat 确认仅 1 个 ESTABLISHED 连接。
### 5.3 前端
**关键设计决策**
1. **状态机 `connState`**:基于 status + eventCount + lastEventTime 推断 5 种状态(idle/loading/connected/disconnected/error),5 秒未收到 monitor-data 事件判定为 disconnected。
2. **`findSensorValue` 多条件查找**:支持 groupId + name + hardwareName + type 四维匹配,name 用精确匹配 + includes 子串兜底。用于解决 Memory 分组有两条同名 "Memory" 传感器(Virtual/Total)的歧义。
3. **SVG sparkline 无外部依赖**:关键指标历史用 30 点环形 bufferSVG path 自绘,不引入 Chart.js(符合"最小安装、少依赖"偏好)。
4. **`schemaVersion` 守卫**store 订阅 monitor-data 时检查 `schemaVersion !== 1` 并 warn,未来 Kernel 升级 schema 时前端不会静默解析错误数据。
5. **模块卸载不停止 Kernel**`dispose` 仅取消事件订阅,不调用 `monitor_stop`。Kernel 生命周期由 ProcessManager 全局管理,与模块 UI 生命周期解耦。
6. **Ready 边沿触发拉取快照**watch `store.status?.ready` 从 false→true 时主动 `fetchSnapshot()`,避免 Kernel 冷启动就绪后 UI 等待下一个 SSE tick 才有数据。
**传感器名匹配**(通过 `curl /snapshot` 验证实际传感器名):
| 指标 | 查找逻辑 | 说明 |
|------|----------|------|
| CPU 封装温度 | `name: 'CPU Package', type: 'temperature'` | — |
| CPU 总负载 | `name: 'CPU Total', type: 'load'` | — |
| 内存负载 | `name: 'Memory', hardwareName: 'Total Memory', type: 'load'` | Memory 分组有两条同名传感器(Virtual/Total),需用 hardwareName 消歧 |
| Intel GPU 3D 负载 | `name: 'D3D 3D'` ?? `name: 'GPU Core'` ?? `type: 'load'` | LHB 实际输出名是 "D3D 3D",无 "GPU" 前缀 |
| 存储温度 | `type: 'temperature'` | 用 type 查找 |
**前端踩坑**
1. **`lucide-vue-next` 模块未找到**:项目实际使用 `@lucide/vue`(见 package.json),而非 `lucide-vue-next`
2. **`connState === 'idle'` 类型比较错误**vue-tsc 报 TS2367。v-else-if 已处理 idle 分支,v-else 块内 connState 类型被收窄为非 idle`=== 'idle'` 永假。修复:移除该比较。
### 5.4 稳定性与打包
**冷启动**:约 5 秒(`computer.Open()` 驱动加载占绝大部分,`Update()` 本身约 200ms)。前端 Loading 超时阈值设为 10 秒,轮询间隔 500ms。
**崩溃恢复验证**kill ThingHK 进程后,ProcessManager 3s 巡检检测崩溃 → 800ms 端口等待 → spawn 新 Kernel → `register_auto_reconnect` 监听 `process-status-changed(running)` 重新订阅 SSE。旧 SSE 循环正确退出,无并发订阅问题。重启后 ready=TruesensorCount=117(与崩溃前一致)。
**退出清理验证**
- **正常退出**quit_app / Ctrl+C):`stop_all()` 同步 kill + 3s 超时 wait,无残留。
- **异常崩溃**kill thing.exe):Windows Job Object`JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE`)兜底自动清理,无残留。
**打包验证**
- `tauri build` 生成 MSI31 MB+ NSIS22.3 MB)安装包。
- ThingHK.exe18.3 MB)通过 [tauri.conf.json](file:///d:/Atie/Gitea/Thing/src-tauri/tauri.conf.json) `"resources": ["binaries/*"]` 打包进安装包。
- `BaseDirectory::Resource` 在 release 模式下正确解析到 `<exe_dir>/binaries/ThingHK.exe`Tauri 2 在打包后指向 exe 所在目录,而非 `resources/` 子目录)。
- Release exe 直接运行:Kernel 自动启动成功,ready=TruesensorCount=117。
**打包踩坑**`tauri build` 在 TRAE 环境失败 `error: invalid value '1' for '--ci'`。TRAE 环境设置了 `CI=1`,导致 tauri CLI 误解析。修复:构建前清除 `$env:CI = $null`
## 6. 文件索引
### C# Kernel
| 文件 | 职责 |
|------|------|
| [ThingHK.csproj](file:///d:/Atie/Gitea/Thing/ThingHK/ThingHK.csproj) | .NET 8 AOT 配置,`Microsoft.NET.Sdk.Web` + ASP.NET Core minimal API |
| [Program.cs](file:///d:/Atie/Gitea/Thing/ThingHK/Program.cs) | `serve`/`scan` 双子命令(System.CommandLine),默认无参数等价 `serve --port 8730 --basic` |
| [Contracts.cs](file:///d:/Atie/Gitea/Thing/ThingHK/Contracts.cs) | 数据契约 + `ThingHKJsonContext`JSON 源生成,camelCase |
| [HardwareManager.cs](file:///d:/Atie/Gitea/Thing/ThingHK/HardwareManager.cs) | `HardwareManager`(封装 LHB Computer,快慢通道分类)+ `SamplingScheduler`PeriodicTimer 分频)+ `SnapshotCache`(线程安全缓存)+ `SnapshotVisitor`(递归收集) |
| [HttpEndpoints.cs](file:///d:/Atie/Gitea/Thing/ThingHK/HttpEndpoints.cs) | 5 个路由 + 全局异常中间件 + `KernelHost`(统一生命周期) |
| [rd.xml](file:///d:/Atie/Gitea/Thing/ThingHK/rd.xml) | LHB 反射根描述符,`preserve="all"` 兜底 AOT trimming |
### Rust 集成
| 文件 | 职责 |
|------|------|
| [monitor_kernel.rs](file:///d:/Atie/Gitea/Thing/src-tauri/src/monitor_kernel.rs) | Tauri 侧 Kernel 客户端:复制二进制、构造 `StartProcessParams`、轮询就绪、SSE 订阅、写入熔断 + 自动重连 |
| [lib.rs](file:///d:/Atie/Gitea/Thing/src-tauri/src/lib.rs) | 6 个 monitor 命令注册 + setup 自动启动 |
| [process_manager.rs](file:///d:/Atie/Gitea/Thing/src-tauri/src/process_manager.rs) | 通用进程管理(复用,含崩溃重启 + Job Object |
### 前端
| 文件 | 职责 |
|------|------|
| [monitorStore.ts](file:///d:/Atie/Gitea/Thing/src/stores/monitorStore.ts) | Pinia store:状态管理 + 事件订阅 + `connState` 状态机 + `findSensorValue` |
| [MonitorModule.vue](file:///d:/Atie/Gitea/Thing/src/modules/monitor/MonitorModule.vue) | 3 Tab UI(概览/详细/设置)+ 关键指标卡 + SVG sparkline + Accordion 分组 |
| [index.ts](file:///d:/Atie/Gitea/Thing/src/modules/monitor/index.ts) | 模块配置和 lifecycle 钩子 |
### 二进制
| 文件 | 说明 |
|------|------|
| [src-tauri/binaries/ThingHK.exe](file:///d:/Atie/Gitea/Thing/src-tauri/binaries/ThingHK.exe) | Kernel AOT 产物(18.3 MB),随安装包分发,运行时复制到 `{app_data_dir}/monitor/cores/ThingHK.exe` |
### 用法
```bash
# 默认 serve(无参数等价)
ThingHK.exe serve --port 8730 --basic --fast-ms 1000 --slow-ms 5000
# 传感器覆盖矩阵测试
ThingHK.exe scan --json --basic
```
## 7. 后续可选优化
以下优化不阻塞当前版本发布,可在后续迭代中考虑:
1. **ProcessManager Mutex 锁阻塞问题**`check_and_cleanup` 中 800ms `sleep`(端口等待)在持有 `processes` Mutex 锁的情况下执行,会阻塞所有进程状态查询。建议将重启逻辑移出锁作用域(已知问题,与 proxy 模块共享)。
2. **提权策略**:通过 Windows 任务计划程序实现免 UAC 管理员启动,解锁 SMBus/EC/部分 GPU 传感器。当前以普通权限运行,覆盖大部分 CPU/GPU/内存/存储温度。
3. **多机型回归**AMD Ryzen / NVIDIA GPU / 笔记本场景的实际传感器覆盖度验证(需实际硬件)。
4. **Kernel 版本管理**`prepare_kernel` 只在文件不存在时复制,不会覆盖更新。建议增加版本比对自动更新。
+9
View File
@@ -0,0 +1,9 @@
<!--
反射根描述符:防止 Native AOT 裁剪掉 LibreHardwareMonitorLib 内部通过反射加载的硬件驱动类型。
LHB 使用 Activator.CreateInstance + 反射枚举插件类型,未保留会导致运行时 MissingMethodException。
这里以宽松模式保留整个 LHB 程序集,确保兼容性;后续若体积过大再收窄。
-->
<linker>
<assembly fullname="LibreHardwareMonitorLib" preserve="all" />
<assembly fullname="LibreHardwareMonitorLib.Hardware" preserve="all" />
</linker>