Files
Evergreen-Health/Evergreen.Health.Application/Health/Integrations/Ib43/Ib43Parser.cs
T
2026-11-11 16:11:41 +08:00

176 lines
8.5 KiB
C#

using System.Text.Json;
namespace Evergreen.Health.Application.Health.Integrations.Ib43;
public sealed record Ib43Reading(double? Value, DateTimeOffset ReceivedAt, string Source);
public sealed class Ib43Report
{
public bool Accepted { get; internal set; }
public string Raw { get; init; }
public DateTimeOffset ReceivedAt { get; init; }
public List<string> Issues { get; } = [];
public Dictionary<string, Ib43Reading> Readings { get; } = new(StringComparer.Ordinal);
public Dictionary<string, JsonElement> Fields { get; } = new(StringComparer.Ordinal);
public HashSet<string> Kinds { get; } = [];
}
/// <summary>只解析协议事实;不产生控制、健康告警或睡眠分期。</summary>
public sealed class Ib43Parser
{
public Ib43Report Parse(string topic, string payload, Ib43Binding binding, DateTimeOffset receivedAt)
{
var r = new Ib43Report { Raw = payload, ReceivedAt = receivedAt };
if (topic != binding.PostTopic) return Reject(r, "topic-mismatch");
try
{
using var document = JsonDocument.Parse(payload);
var root = document.RootElement;
if (HasDuplicates(root)) return Reject(r, "duplicate-json-key");
if (root.ValueKind != JsonValueKind.Object ||
!root.TryGetProperty("method", out var method) || method.ValueKind != JsonValueKind.String ||
method.GetString() != "thing.property.post" ||
!root.TryGetProperty("params", out var p) || p.ValueKind != JsonValueKind.Object)
return Reject(r, "invalid-envelope");
if (!p.TryGetProperty("deviceName", out var sn) || sn.ValueKind != JsonValueKind.String ||
sn.GetString() != binding.SerialNumber ||
(p.TryGetProperty("productKey", out var pk) &&
(pk.ValueKind != JsonValueKind.String || pk.GetString() != binding.ProductKey)))
return Reject(r, "identity-mismatch");
if (root.TryGetProperty("version", out var version) &&
(version.ValueKind != JsonValueKind.String || version.GetString() != "1.0"))
return Reject(r, "unsupported-version");
r.Accepted = true;
foreach (var key in new[] { "firmwareVer", "characteristic", "deviceMode" })
{
if (!p.TryGetProperty(key, out var value)) continue;
r.Kinds.Add("startup");
var valid = key switch
{
"firmwareVer" => value.ValueKind == JsonValueKind.String,
"characteristic" => value.ValueKind == JsonValueKind.Object,
_ => value.ValueKind == JsonValueKind.Number && value.TryGetInt32(out _)
};
if (valid) r.Fields[key] = value.Clone(); else r.Issues.Add(key + ":invalid-type");
}
if (p.TryGetProperty("HR", out var hr))
{
r.Kinds.Add("periodic");
if (Numbers(hr, 6, "HR", r) is { } values)
{
Vitals(r, "left", values[0], values[1], values[2], "HR");
Vitals(r, "right", values[3], values[4], values[5], "HR");
}
}
if (p.TryGetProperty("airbagsPerson", out var person))
{
r.Kinds.Add("periodic");
if (Numbers(person, 2, "airbagsPerson", r) is { } values)
{
if (values.All(v => v is 0 or 1))
{
r.Fields["airbagsPerson"] = person.Clone();
if (binding.PersonLeftIndex is int index)
{
Add(r, "left.presence", values[index], "airbagsPerson");
Add(r, "right.presence", values[1 - index], "airbagsPerson");
}
}
else r.Issues.Add("airbagsPerson:invalid-enum");
}
}
foreach (var (key, length) in new[] { ("airbagsPressure", 8), ("motorHeight", 2),
("motorStatus", 4), ("motorMode", 2), ("snoreStatus", 2),
("LeftRegularTime", 10), ("RightRegularTime", 10) })
{
if (!p.TryGetProperty(key, out var value)) continue;
r.Kinds.Add("periodic");
if (Numbers(value, length, key, r) is not { } numbers) continue;
if (key == "motorStatus" && numbers.Any(n => n is not (0 or 1 or 2)))
r.Issues.Add("motorStatus:invalid-enum");
else r.Fields[key] = value.Clone();
}
if (p.TryGetProperty("adaptive", out var adaptive))
{
r.Kinds.Add("periodic");
if (adaptive.ValueKind == JsonValueKind.Array && adaptive.GetArrayLength() == 2 &&
adaptive.EnumerateArray().All(a => a.ValueKind == JsonValueKind.Object))
r.Fields["adaptive"] = adaptive.Clone();
else r.Issues.Add("adaptive:invalid-shape");
}
var sleep = Alias(p, "SleepReportNew", r);
if (sleep is { } s)
{
r.Kinds.Add("event");
if (s.ValueKind != JsonValueKind.Object) r.Issues.Add("SleepReportNew:invalid-type");
else if (Alias(s, "ibNew", r) is { } ib && Numbers(ib, 12, "ibNew", r) is { } values)
{
Presence(r, "left", values[0], "ibNew");
Presence(r, "right", values[6], "ibNew");
Vitals(r, "left", values[4], values[3], values[1], "ibNew");
Vitals(r, "right", values[10], values[9], values[7], "ibNew");
}
else r.Issues.Add("ibNew:missing-or-invalid");
}
if (r.Kinds.Count == 0) r.Issues.Add("unrecognized-report");
}
catch (JsonException) { return Reject(r, "invalid-json"); }
return r;
}
private static Ib43Report Reject(Ib43Report r, string issue)
{ r.Accepted = false; r.Issues.Add(issue); return r; }
private static bool HasDuplicates(JsonElement node) => node.ValueKind switch
{
JsonValueKind.Object => node.EnumerateObject().GroupBy(p => p.Name, StringComparer.Ordinal).Any(g => g.Count() > 1) ||
node.EnumerateObject().Any(p => HasDuplicates(p.Value)),
JsonValueKind.Array => node.EnumerateArray().Any(HasDuplicates),
_ => false
};
private static JsonElement? Alias(JsonElement p, string key, Ib43Report r)
{
var plain = p.TryGetProperty(key, out var a);
var spaced = p.TryGetProperty(" " + key, out var b);
// 双键即使内容相同也拒绝该字段,避免依赖 JSON 文本格式决定覆盖顺序。
if (plain && spaced) { r.Issues.Add(key + ":ambiguous-alias"); return null; }
if (spaced) { r.Fields["alias." + key] = JsonSerializer.SerializeToElement(" " + key); return b; }
return plain ? a : null;
}
private static double[] Numbers(JsonElement value, int length, string key, Ib43Report r)
{
if (value.ValueKind != JsonValueKind.Array || value.GetArrayLength() != length)
{ r.Issues.Add(key + ":invalid-length"); return null; }
var numbers = new double[length];
var i = 0;
foreach (var item in value.EnumerateArray())
{
if (item.ValueKind != JsonValueKind.Number || !item.TryGetDouble(out var number) || !double.IsFinite(number))
{ r.Issues.Add(key + ":invalid-number"); return null; }
numbers[i++] = number;
}
return numbers;
}
private static void Add(Ib43Report r, string key, double? value, string source)
=> r.Readings[key] = new(value, r.ReceivedAt, source);
private static void Presence(Ib43Report r, string side, double value, string source)
{
if (value is 0 or 1) Add(r, side + ".presence", value, source);
else r.Issues.Add(side + ".presence:invalid-enum");
}
private static void Vitals(Ib43Report r, string side, double hr, double respiration, double snore, string source)
{
if (hr >= 0) Add(r, side + ".heartRate", hr == 0 ? null : hr, source);
else r.Issues.Add(side + ".heartRate:negative");
if (respiration >= 0) Add(r, side + ".respiration", respiration == 0 ? null : respiration, source);
else r.Issues.Add(side + ".respiration:negative");
if (snore is 0 or 1) Add(r, side + ".snoring", snore, source);
else r.Issues.Add(side + ".snoring:invalid-enum");
}
}