176 lines
8.5 KiB
C#
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");
|
|
}
|
|
}
|