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 Issues { get; } = []; public Dictionary Readings { get; } = new(StringComparer.Ordinal); public Dictionary Fields { get; } = new(StringComparer.Ordinal); public HashSet Kinds { get; } = []; } /// 只解析协议事实;不产生控制、健康告警或睡眠分期。 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"); } }