diff --git a/Admin.NET.Test/Admin.NET.Test.csproj b/Admin.NET.Test/Admin.NET.Test.csproj
index 832536f..dcb7604 100644
--- a/Admin.NET.Test/Admin.NET.Test.csproj
+++ b/Admin.NET.Test/Admin.NET.Test.csproj
@@ -25,6 +25,7 @@
+
diff --git a/Admin.NET.Test/Health/BedPresenceAutomationTests.cs b/Admin.NET.Test/Health/BedPresenceAutomationTests.cs
new file mode 100644
index 0000000..a51af46
--- /dev/null
+++ b/Admin.NET.Test/Health/BedPresenceAutomationTests.cs
@@ -0,0 +1,160 @@
+using Evergreen.Health.Application.Health.Devices.Curtain;
+using Evergreen.Health.Application.Health.Devices.Lighting;
+using Evergreen.Health.Application.Health.Integrations.HomeAssistant;
+using Evergreen.Health.Application.Health.Scenes;
+using Evergreen.Health.Application.Health.Sleep;
+using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Logging.Abstractions;
+using Microsoft.Extensions.Options;
+using System.Text.Json;
+using Xunit;
+
+namespace Admin.NET.Test.Health;
+
+public sealed class BedPresenceAutomationTests
+{
+ private const string Binding = "a98788f2aeeb40b68f145a182d99f832";
+ private const string Light = "light.test_bedroom";
+ private const string Curtain = "cover.test_curtain";
+ private static BedPresenceSample Sample(BedPresence presence, DateTimeOffset at, Guid connection) =>
+ new(Binding, "evergreen-test", "IB43SIM001", BedSide.Left, true, presence, at, connection);
+
+ [Fact]
+ public void Edge_requires_fresh_empty_baseline_on_the_same_bed_and_connection()
+ {
+ var edge = new BedPresenceEdge();
+ var at = DateTimeOffset.UtcNow;
+ var connection = Guid.NewGuid();
+ Assert.False(edge.Observe(Sample(BedPresence.Occupied, at, connection), at));
+ Assert.False(edge.Observe(Sample(BedPresence.Empty, at, connection), at));
+ Assert.True(edge.Observe(Sample(BedPresence.Occupied, at, connection), at));
+ Assert.False(edge.Observe(Sample(BedPresence.Occupied, at, connection), at));
+ Assert.False(edge.Observe(Sample(BedPresence.Empty, at.AddSeconds(-1), connection), at));
+ Assert.False(edge.Observe(Sample(BedPresence.Occupied, at, connection), at));
+ Assert.False(edge.Observe(Sample(BedPresence.Empty, at, connection), at));
+ Assert.False(edge.Observe(Sample(BedPresence.Occupied, at.AddSeconds(61), connection), at.AddSeconds(61)));
+ Assert.False(edge.Observe(Sample(BedPresence.Empty, at, connection), at.AddSeconds(61)));
+ Assert.False(edge.Observe(Sample(BedPresence.Occupied, at, Guid.NewGuid()), at));
+ }
+
+ [Fact]
+ public async Task Preview_requires_configured_simulation_identity_and_never_executes_action()
+ {
+ var options = Options.Create(new BedPresenceAutomationOptions
+ { Mode = BedAutomationMode.Preview, BindingId = Binding, ActionKey = "curtain-close", TargetEntityId = Curtain });
+ var action = new NeverExecuteAction();
+ var logger = new PreviewLogger();
+ using var automation = new BedPresenceAutomation(options, [action], TimeProvider.System, logger);
+ await automation.StartAsync(default);
+ try
+ {
+ var connection = Guid.NewGuid();
+ var at = DateTimeOffset.UtcNow;
+ var occupied = Sample(BedPresence.Occupied, at, connection);
+ var empty = occupied with { Presence = BedPresence.Empty };
+ // 这些样本即使构成上床边沿也不能进入规则。
+ Assert.False(options.Value.Matches(empty with { Side = BedSide.Right }));
+ Assert.False(options.Value.Matches(empty with { Simulation = false }));
+ Assert.False(options.Value.Matches(empty with { BindingId = "someone-else" }));
+ automation.Observe(occupied, default);
+ automation.Observe(empty, default);
+ automation.Observe(occupied, default);
+ await logger.Preview.Task.WaitAsync(TimeSpan.FromSeconds(3));
+ Assert.Contains("Action=curtain-close", logger.Preview.Task.Result);
+ Assert.Contains(Curtain, logger.Preview.Task.Result);
+ Assert.Equal(0, action.Calls);
+ }
+ finally { await automation.StopAsync(default); }
+ }
+
+ [Theory]
+ [InlineData("closed", "already-closed")]
+ [InlineData("closing", "already-closing")]
+ [InlineData("opening", "state-not-open")]
+ public async Task Curtain_action_leaves_closed_or_moving_curtain_alone(string state, string expected)
+ {
+ var (action, gateway) = CurtainFixture(state, 2, "closed");
+ Assert.Equal(expected, await action.ExecuteAsync(Curtain, default));
+ Assert.Empty(gateway.Calls);
+ }
+
+ [Theory]
+ [InlineData("closed", "readback-confirmed")]
+ [InlineData("closing", "sent-readback-unconfirmed")]
+ public async Task Curtain_action_sends_one_close_and_waits_for_closed_readback(string reported, string expected)
+ {
+ var (action, gateway) = CurtainFixture("open", 2, reported);
+ Assert.Equal("target-not-configured", await action.ExecuteAsync("cover.other", default));
+ Assert.Equal(expected, await action.ExecuteAsync(Curtain, default));
+ var command = Assert.Single(gateway.Calls);
+ Assert.Equal(Curtain, command.EntityId);
+ Assert.Equal("close_cover", command.Service);
+ }
+
+ [Fact]
+ public async Task Curtain_action_requires_close_capability_before_sending()
+ {
+ var (action, gateway) = CurtainFixture("open", 1, "closed");
+ await Assert.ThrowsAsync(() => action.ExecuteAsync(Curtain, default));
+ Assert.Empty(gateway.Calls);
+ }
+
+ private static (BedPresenceCurtainCloseAction, FakeHaGateway) CurtainFixture(string state, int features, string closeResult)
+ {
+ var gateway = new FakeHaGateway { CoverCloseResult = closeResult };
+ gateway.States[Curtain] = new() { EntityId = Curtain, State = state,
+ Attributes = JsonSerializer.SerializeToElement(new { supported_features = features }) };
+ var options = Options.Create(new HomeAssistantOptions { Entities = new() { Curtain = Curtain } });
+ var runner = new DeviceActionRunner(gateway, TimeProvider.System, new() { VerificationDelay = TimeSpan.Zero });
+ return (new(new CurtainModule(runner, options), runner, options), gateway);
+ }
+
+ [Fact]
+ public async Task Light_action_starts_only_target_and_verifies_brightness_without_restarting_active_fade()
+ {
+ var gateway = new FakeHaGateway();
+ var time = new ManualTestTimeProvider();
+ gateway.States[Light] = new() { EntityId = Light, State = "off", Attributes = JsonSerializer.SerializeToElement(new { }) };
+ gateway.States["light.other"] = new() { EntityId = "light.other", State = "off" };
+ var options = Options.Create(new HomeAssistantOptions { Entities = new() { AmbientLight = Light } });
+ var lights = new HomeAssistantLightTransitionService(gateway, new LightOffDispatcher([]), options,
+ NullLogger.Instance, new(), time);
+ var execution = new DeviceExecutionOptions { VerificationDelay = TimeSpan.Zero };
+ var action = new BedPresenceSleepLightAction(lights, new DeviceActionRunner(gateway, time, execution), execution, options);
+ try
+ {
+ Assert.Equal("target-not-configured", await action.ExecuteAsync("light.other", default));
+ Assert.Empty(gateway.Calls);
+ Assert.Equal("readback-confirmed", await action.ExecuteAsync(Light, default));
+ Assert.Equal("already-running", await action.ExecuteAsync(Light, default));
+ var call = Assert.Single(gateway.Calls);
+ Assert.Equal(Light, call.EntityId);
+ Assert.Equal(50, call.Data!["brightness_pct"]);
+ Assert.Equal("off", gateway.States["light.other"].State);
+ }
+ finally { await lights.StopAsync(default); }
+ }
+
+ private sealed class NeverExecuteAction : IBedPresenceAction
+ {
+ public int Calls;
+ public string Key => "curtain-close";
+ public string Description => "关闭窗帘";
+ public bool SupportsTarget(string entityId) => entityId == Curtain;
+ public Task ExecuteAsync(string entityId, CancellationToken token)
+ { Interlocked.Increment(ref Calls); throw new InvalidOperationException("Preview cannot execute"); }
+ }
+
+ private sealed class PreviewLogger : ILogger
+ {
+ public TaskCompletionSource Preview { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously);
+ public IDisposable BeginScope(TState state) where TState : notnull => null!;
+ public bool IsEnabled(LogLevel logLevel) => true;
+ public void Log(LogLevel logLevel, EventId eventId, TState state, Exception? exception,
+ Func formatter)
+ {
+ var message = formatter(state, exception);
+ if (message.StartsWith("Bed presence preview:", StringComparison.Ordinal)) Preview.TrySetResult(message);
+ }
+ }
+}
diff --git a/Admin.NET.Test/Health/DeviceArchitectureTests.cs b/Admin.NET.Test/Health/DeviceArchitectureTests.cs
index e564612..5d3ad6d 100644
--- a/Admin.NET.Test/Health/DeviceArchitectureTests.cs
+++ b/Admin.NET.Test/Health/DeviceArchitectureTests.cs
@@ -147,6 +147,52 @@ public sealed class DeviceArchitectureTests
Assert.Equal("light.bedroom", device.PrimaryControlEntityId);
}
+ [Fact]
+ public async Task Configured_inventory_filters_retired_devices_even_if_ha_cache_still_reports_them()
+ {
+ var services = new ServiceCollection();
+ services.AddLogging(); services.AddOptions(); services.AddHttpClient(); services.AddHealthDevices();
+ var gateway = new FakeHaGateway { ReadsToFail = 1 };
+ services.AddSingleton(gateway);
+ services.AddSingleton(Options.Create(new HomeAssistantOptions
+ {
+ OnlyConfiguredDevices = true,
+ Entities = new()
+ {
+ Climate = "climate.keep", Temperature = "sensor.keep_temperature", Curtain = "cover.keep",
+ Dehumidifier = "humidifier.keep", FragranceSwitch = "switch.keep_fragrance", Speaker = "media_player.keep"
+ },
+ DisplayOnlyDevices = [new() { EntityId = "switch.wake_fragrance", Name = "香氛机 唤醒", Category = "Fragrance" }]
+ }));
+ using var provider = services.BuildServiceProvider();
+ var entities = new[] { "climate.keep", "sensor.keep_temperature", "cover.keep", "humidifier.keep",
+ "switch.keep_fragrance", "media_player.keep", "switch.wake_fragrance", "light.removed", "switch.removed_outlet", "sensor.removed_weight" };
+ var registry = new HomeAssistantRegistries
+ {
+ Devices = entities.Select(id => new HomeAssistantDeviceRegistryItem { Id = id, Name = id }).ToList(),
+ Entities = entities.Select(id => new HomeAssistantEntityRegistryItem { DeviceId = id, EntityId = id }).ToList()
+ };
+ var state = entities.ToDictionary(id => id, id => new HomeAssistantState { EntityId = id, State = "on" });
+ var catalog = provider.GetRequiredService();
+ foreach (var inventory in new[] { catalog.BuildDiscoveredDevices(registry, state), catalog.BuildDiscoveredDevices(null, state) })
+ {
+ Assert.Equal(7, inventory.Count);
+ Assert.DoesNotContain(inventory.SelectMany(device => device.Entities), entity => entity.EntityId.Contains("removed"));
+ var wake = Assert.Single(inventory, device => device.Entities.Any(entity => entity.EntityId == "switch.wake_fragrance"));
+ Assert.Null(wake.PrimaryControlEntityId);
+ Assert.True(wake.Online);
+ var sleep = Assert.Single(inventory, device => device.Entities.Any(entity => entity.EntityId == "switch.keep_fragrance"));
+ Assert.Equal("switch.keep_fragrance", sleep.PrimaryControlEntityId);
+ }
+ var modules = provider.GetRequiredService();
+ Assert.DoesNotContain(modules.Modules, module => module.Key is "lighting" or "outlet");
+ Assert.Throws(() => modules.ForParameter("brightness"));
+ var lights = provider.GetRequiredService();
+ await Assert.ThrowsAsync(() => lights.StartSleepAsync(2700, default));
+ Assert.Equal(1, gateway.ReadsToFail);
+ Assert.Empty(gateway.Calls);
+ }
+
[Fact]
public async Task ShutdownContinuesAfterAnIndividualDeviceFailure()
{
@@ -218,7 +264,7 @@ public sealed class DeviceArchitectureTests
services.AddHealthDevices();
using var provider = services.BuildServiceProvider(new ServiceProviderOptions { ValidateOnBuild = true, ValidateScopes = true });
Assert.NotNull(provider.GetRequiredService());
- Assert.Equal(2, provider.GetServices().Count());
+ Assert.Equal(4, provider.GetServices().Count());
Assert.DoesNotContain(provider.GetRequiredService().Modules, module => module.Key == "mattress");
Assert.Empty(provider.GetServices());
Assert.Throws(() => provider.GetRequiredService().ForParameter("mattress_left_heating"));
diff --git a/Admin.NET.Test/Health/DeviceTestDoubles.cs b/Admin.NET.Test/Health/DeviceTestDoubles.cs
index 4452a9f..1e22b85 100644
--- a/Admin.NET.Test/Health/DeviceTestDoubles.cs
+++ b/Admin.NET.Test/Health/DeviceTestDoubles.cs
@@ -15,6 +15,7 @@ internal sealed class FakeHaGateway : IHomeAssistantGateway
public ConcurrentQueue Calls { get; } = new();
public string? FailEntity { get; set; }
public int ReadsToFail { get; set; }
+ public string? CoverCloseResult { get; set; }
public TimeProvider Time { get; set; } = TimeProvider.System;
public Task> GetStatesAsync(CancellationToken cancellationToken = default)
@@ -34,6 +35,7 @@ internal sealed class FakeHaGateway : IHomeAssistantGateway
var state = service switch
{
"turn_on" => "on", "turn_off" => "off",
+ "close_cover" when CoverCloseResult is not null => CoverCloseResult,
"select_option" => data["option"]!.ToString()!,
"set_value" => data["value"]!.ToString()!, _ => States.GetValueOrDefault(entity)?.State ?? "unknown"
};
diff --git a/Admin.NET.Test/Health/Ib43ConfigurationTests.cs b/Admin.NET.Test/Health/Ib43ConfigurationTests.cs
new file mode 100644
index 0000000..bbbb386
--- /dev/null
+++ b/Admin.NET.Test/Health/Ib43ConfigurationTests.cs
@@ -0,0 +1,48 @@
+using Evergreen.Health.Application.Health.Integrations.Ib43;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Options;
+using Xunit;
+
+namespace Admin.NET.Test.Health;
+
+[CollectionDefinition("Ib43Environment", DisableParallelization = true)]
+public sealed class Ib43EnvironmentCollection { }
+
+[Collection("Ib43Environment")]
+public sealed class Ib43ConfigurationTests
+{
+ [Fact]
+ public void Environment_overrides_defaults_after_binding_and_before_validation()
+ {
+ var names = new[] { "Ib43__Enabled", "Ib43__Password" };
+ var original = names.Select(Environment.GetEnvironmentVariable).ToArray();
+ try
+ {
+ Environment.SetEnvironmentVariable(names[0], "true");
+ Environment.SetEnvironmentVariable(names[1], "test-only-secret");
+ var services = new ServiceCollection();
+ services.AddOptions().Configure(options =>
+ {
+ options.Enabled = false; options.Password = "";
+ options.Devices = [new() { ProductKey = "test", SerialNumber = "sim" }];
+ }).PostConfigure(Ib43Configuration.ApplyEnvironmentOverrides)
+ .Validate(options => options.IsValid());
+ using var provider = services.BuildServiceProvider();
+ var options = provider.GetRequiredService>().Value;
+ Assert.True(options.Enabled);
+ Assert.Equal("test-only-secret", options.Password);
+ Assert.Single(options.Devices);
+
+ Environment.SetEnvironmentVariable(names[0], null);
+ Environment.SetEnvironmentVariable(names[1], null);
+ var defaults = new Ib43Options();
+ Ib43Configuration.ApplyEnvironmentOverrides(defaults);
+ Assert.False(defaults.Enabled);
+ Assert.Empty(defaults.Password);
+ }
+ finally
+ {
+ for (var i = 0; i < names.Length; i++) Environment.SetEnvironmentVariable(names[i], original[i]);
+ }
+ }
+}
diff --git a/Admin.NET.Test/Health/Ib43MqttIntegrationTests.cs b/Admin.NET.Test/Health/Ib43MqttIntegrationTests.cs
new file mode 100644
index 0000000..ebf8236
--- /dev/null
+++ b/Admin.NET.Test/Health/Ib43MqttIntegrationTests.cs
@@ -0,0 +1,164 @@
+using Evergreen.Health.Application.Health.Integrations.Ib43;
+using Evergreen.Health.Application.Health.Entities;
+using Evergreen.Health.Application.Health.Devices.Curtain;
+using Evergreen.Health.Application.Health.Integrations.HomeAssistant;
+using Evergreen.Health.Application.Health.Scenes;
+using Evergreen.Health.Application.Health.Sleep;
+using SqlSugar;
+using Microsoft.Extensions.Logging.Abstractions;
+using Microsoft.Extensions.Options;
+using MQTTnet;
+using MQTTnet.Client;
+using MQTTnet.Protocol;
+using MQTTnet.Server;
+using System.Net;
+using System.Net.Sockets;
+using System.Text.Json.Nodes;
+using System.Text.Json;
+using Xunit;
+
+namespace Admin.NET.Test.Health;
+
+public sealed class Ib43MqttIntegrationTests
+{
+ private const string LeftBinding = "a98788f2aeeb40b68f145a182d99f832";
+ private const string TargetCurtain = "cover.test_curtain";
+
+ [Fact]
+ public async Task Real_MQTT_receives_supplier_examples_and_resubscribes_after_broker_restart()
+ {
+ // 只监听 loopback,随机端口;不访问用户的 EMQX、数据库或家具。
+ using var reservation = new TcpListener(IPAddress.Loopback, 0);
+ reservation.Start();
+ var port = ((IPEndPoint)reservation.LocalEndpoint).Port;
+ reservation.Stop();
+ var factory = new MqttFactory();
+ using var broker = factory.CreateMqttServer(new MqttServerOptionsBuilder()
+ .WithDefaultEndpoint().WithDefaultEndpointPort(port)
+ .WithDefaultEndpointBoundIPAddress(IPAddress.Loopback).Build());
+ await broker.StartAsync();
+ var binding = new Ib43Binding { ProductKey = "evergreen-test", SerialNumber = "IB43SIM001" };
+ var options = Options.Create(new Ib43Options { Enabled = true, Host = "127.0.0.1", Port = port,
+ Username = "local-test", Password = "local-test-only", Devices = [binding] });
+ var store = new Ib43StateStore(options, new(), TimeProvider.System);
+ using var sink = new DatabaseSink(binding);
+ var gateway = new FakeHaGateway { CoverCloseResult = "closed" };
+ gateway.States[TargetCurtain] = new() { EntityId = TargetCurtain, State = "open",
+ Attributes = JsonSerializer.SerializeToElement(new { supported_features = 2 }) };
+ var haOptions = Options.Create(new HomeAssistantOptions { Entities = new() { Curtain = TargetCurtain } });
+ var execution = new DeviceExecutionOptions { VerificationDelay = TimeSpan.Zero };
+ var runner = new DeviceActionRunner(gateway, TimeProvider.System, execution);
+ var curtainAction = new BedPresenceCurtainCloseAction(new CurtainModule(runner, haOptions), runner, haOptions);
+ using var automation = new BedPresenceAutomation(Options.Create(new BedPresenceAutomationOptions
+ { Mode = BedAutomationMode.Execute, BindingId = LeftBinding, ActionKey = "curtain-close", TargetEntityId = TargetCurtain }),
+ [curtainAction], TimeProvider.System, NullLogger.Instance);
+ using var service = new Ib43MqttService(options, new Ib43MqttTransportFactory(), store,
+ TimeProvider.System, NullLogger.Instance, sink, automation);
+ using var publisher = factory.CreateMqttClient();
+ try
+ {
+ await automation.StartAsync(default);
+ await service.StartAsync(default);
+ await WaitFor(() => store.Snapshot()[0].Transport == "connected");
+ await publisher.ConnectAsync(new MqttClientOptionsBuilder().WithTcpServer("127.0.0.1", port)
+ .WithClientId("ib43-local-simulator").Build());
+ foreach (var name in new[] { "startup.pdf-example.json", "realtime.pdf-example.json", "sleep-event.pdf-keys.json" })
+ {
+ var json = JsonNode.Parse(File.ReadAllText(Path.Combine(AppContext.BaseDirectory, "Ib43Examples", name)));
+ json["params"]["deviceName"] = binding.SerialNumber;
+ if (json["params"]["productKey"] != null) json["params"]["productKey"] = binding.ProductKey;
+ await publisher.PublishAsync(new MqttApplicationMessageBuilder().WithTopic(binding.PostTopic)
+ .WithPayload(json.ToJsonString()).WithQualityOfServiceLevel(MqttQualityOfServiceLevel.AtLeastOnce).Build());
+ }
+ await WaitFor(() => store.Snapshot()[0].Readings.TryGetValue("left.heartRate", out var value) && value.Value == 74);
+ Assert.Equal(80d, store.Snapshot()[0].Readings["right.heartRate"].Value);
+ Assert.Equal(60, store.Snapshot()[0].Fields["characteristic"].Value.GetProperty("reportIerVal").GetInt32());
+ Assert.All(store.Diagnostics(), entry => Assert.Empty(entry.Issues));
+ await WaitFor(() => Volatile.Read(ref sink.SavedReports) == 2);
+ Assert.Equal(74d, (await sink.Repository.Latest(1, LeftBinding))["heartRate"].Value);
+ Assert.Equal(80d, (await sink.Repository.Latest(2, "right-test"))["heartRate"].Value);
+ Assert.Empty(gateway.Calls);
+
+ // 左侧空床后上床才触发。连续重复在床不会重启,右侧的变化不触发左侧规则。
+ await PublishPresence(publisher, binding, 0, 0);
+ await PublishPresence(publisher, binding, 0, 1);
+ await PublishPresence(publisher, binding, 1, 1);
+ await PublishPresence(publisher, binding, 1, 1);
+ await WaitFor(() => Volatile.Read(ref sink.SavedReports) == 6 && gateway.Calls.Count == 1);
+ var close = Assert.Single(gateway.Calls);
+ Assert.Equal(TargetCurtain, close.EntityId);
+ Assert.Equal("close_cover", close.Service);
+ Assert.Equal("closed", gateway.States[TargetCurtain].State);
+ Assert.Equal(1d, (await sink.Repository.Latest(1, LeftBinding))["presence"].Value);
+
+ await broker.StopAsync();
+ await WaitFor(() => store.Snapshot()[0].Transport != "connected");
+ await broker.StartAsync();
+ await WaitFor(() => store.Snapshot()[0].Transport == "connected");
+ using var publisherAfterRestart = factory.CreateMqttClient();
+ await publisherAfterRestart.ConnectAsync(new MqttClientOptionsBuilder().WithTcpServer("127.0.0.1", port).Build());
+ await publisherAfterRestart.PublishAsync(new MqttApplicationMessageBuilder().WithTopic(binding.PostTopic)
+ .WithPayload("{\"method\":\"thing.property.post\",\"params\":{\"deviceName\":\"IB43SIM001\",\"HR\":[75,16,0,81,18,0]}}")
+ .WithQualityOfServiceLevel(MqttQualityOfServiceLevel.AtLeastOnce).Build());
+ await WaitFor(() => store.Snapshot()[0].Readings["left.heartRate"].Value == 75);
+ Assert.Equal(1d, store.Snapshot()[0].Readings["left.presence"].Value);
+ await WaitFor(() => Volatile.Read(ref sink.SavedReports) == 7);
+ Assert.Equal(75d, (await sink.Repository.Latest(1, LeftBinding))["heartRate"].Value);
+ Assert.Single(gateway.Calls);
+ }
+ finally
+ {
+ using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(5));
+ await service.StopAsync(timeout.Token);
+ await automation.StopAsync(timeout.Token);
+ await broker.StopAsync();
+ }
+ }
+
+ private sealed class DatabaseSink : IIb43ReportSink, IDisposable
+ {
+ private readonly SqlSugarClient _db;
+ public readonly Ib43DataRepository Repository;
+ public int SavedReports;
+ public DatabaseSink(Ib43Binding binding)
+ {
+ _db = new(new ConnectionConfig { ConnectionString = "DataSource=:memory:", DbType = DbType.Sqlite,
+ IsAutoCloseConnection = false, InitKeyType = InitKeyType.Attribute,
+ ConfigureExternalServices = new ConfigureExternalServices { EntityService = (p, c) =>
+ {
+ if (p.DeclaringType == typeof(HealthIb43Observation) && p.Name == "Id") c.DataType = "INTEGER";
+ } } });
+ _db.CodeFirst.InitTables();
+ Repository = new(_db);
+ var key = Ib43DataRepository.DeviceKey(binding.ProductKey, binding.SerialNumber);
+ foreach (var (side, user) in new[] { ("left", 1L), ("right", 2L) })
+ _db.Insertable(new HealthIb43Binding { Id = side == "left" ? LeftBinding : "right-test", DeviceKey = key,
+ ProductKey = binding.ProductKey, SerialNumber = binding.SerialNumber, Side = side,
+ ActiveSlot = key + "." + side, UserId = user, Simulation = true }).ExecuteCommand();
+ }
+ public async Task SaveAsync(string reportId, Ib43Binding binding, Ib43Report report, CancellationToken token)
+ {
+ var count = await Repository.Save(reportId, binding, report);
+ Interlocked.Increment(ref SavedReports);
+ return count;
+ }
+ public void Dispose() => _db.Dispose();
+ }
+
+ private static Task PublishPresence(IMqttClient publisher, Ib43Binding binding, int left, int right)
+ {
+ var payload = JsonSerializer.Serialize(new
+ {
+ method = "thing.property.post",
+ @params = new { deviceName = binding.SerialNumber, SleepReportNew = new { ibNew = new[] { left, 0, 0, 16, 74, 0, right, 0, 0, 17, 80, 0 } } }
+ });
+ return publisher.PublishAsync(new MqttApplicationMessageBuilder().WithTopic(binding.PostTopic).WithPayload(payload)
+ .WithQualityOfServiceLevel(MqttQualityOfServiceLevel.AtLeastOnce).Build());
+ }
+
+ private static async Task WaitFor(Func predicate)
+ {
+ using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(12));
+ while (!predicate()) await Task.Delay(20, timeout.Token);
+ }
+}
diff --git a/Admin.NET.Test/Health/Ib43PersistenceTests.cs b/Admin.NET.Test/Health/Ib43PersistenceTests.cs
new file mode 100644
index 0000000..78618a0
--- /dev/null
+++ b/Admin.NET.Test/Health/Ib43PersistenceTests.cs
@@ -0,0 +1,208 @@
+using Admin.NET.Core;
+using Evergreen.Health.Application.Health.Entities;
+using Evergreen.Health.Application.Health.Integrations.Ib43;
+using Evergreen.Health.Application.Health.Services;
+using Microsoft.AspNetCore.Http;
+using Microsoft.Extensions.Options;
+using SqlSugar;
+using System.Security.Claims;
+using Xunit;
+
+namespace Admin.NET.Test.Health;
+
+public sealed class Ib43PersistenceTests : IDisposable
+{
+ private readonly SqlSugarClient _db;
+ private readonly string _databasePath = Path.Combine(Path.GetTempPath(), "ib43-test-" + Guid.NewGuid().ToString("N") + ".db");
+ private readonly Ib43DataRepository _repository;
+ private readonly Clock _clock = new();
+ private readonly Ib43Binding _device = new() { ProductKey = "test", SerialNumber = "sim", Simulation = true };
+ private readonly HttpContextAccessor _context = new() { HttpContext = new DefaultHttpContext() };
+ private readonly HealthIb43Service _service;
+
+ private sealed class Clock : TimeProvider
+ {
+ public DateTimeOffset Now = DateTimeOffset.FromUnixTimeMilliseconds(1000000);
+ public override DateTimeOffset GetUtcNow() => Now;
+ }
+
+ public Ib43PersistenceTests()
+ {
+ _db = new SqlSugarClient(new ConnectionConfig { ConnectionString = "DataSource=" + _databasePath,
+ DbType = DbType.Sqlite, IsAutoCloseConnection = false, InitKeyType = InitKeyType.Attribute,
+ ConfigureExternalServices = new ConfigureExternalServices { EntityService = (property, column) =>
+ {
+ // SQLite 的 rowid 必须声明 INTEGER;MySQL 生产表保持 bigint identity。
+ if (property.DeclaringType == typeof(HealthIb43Observation) && property.Name == "Id") column.DataType = "INTEGER";
+ if (Nullable.GetUnderlyingType(property.PropertyType) != null) column.IsNullable = true;
+ } } });
+ _db.CodeFirst.InitTables();
+ _db.Ado.ExecuteCommand("CREATE TABLE SysUser (Id INTEGER PRIMARY KEY, AccountType INTEGER, Status INTEGER)");
+ _db.Ado.ExecuteCommand("INSERT INTO SysUser VALUES (99,999,1)");
+ foreach (var id in new[] { 1L, 2L, 3L })
+ _db.Insertable(new HealthUser { Id = id, Phone = id.ToString(), DisplayName = "test", PasswordHash = "unused",
+ CreateTime = DateTime.UtcNow, CreateUserName = "test", UpdateUserName = "test" }).ExecuteCommand();
+ _repository = new(_db);
+ var options = Options.Create(new Ib43Options { Devices = [_device] });
+ _service = new(_repository, _context, options, new(options, new(), _clock), _clock);
+ }
+
+ private void User(long id) => _context.HttpContext.User = new ClaimsPrincipal(new ClaimsIdentity(
+ [new Claim("health_user_id", id.ToString())], "test"));
+ private void Admin() => _context.HttpContext.User = new ClaimsPrincipal(new ClaimsIdentity(
+ [new Claim(ClaimConst.UserId, "99"), new Claim(ClaimConst.AccountType, "999")], "test"));
+ private async Task Assign(string side, long user)
+ {
+ Admin();
+ return await _service.Assign(new() { ProductKey = "test", SerialNumber = "sim", Side = side, UserId = user, BedName = "测试床" });
+ }
+ private Ib43Report Report(string fields) => new Ib43Parser().Parse(_device.PostTopic,
+ "{\"method\":\"thing.property.post\",\"params\":{\"deviceName\":\"sim\"," + fields + "}}", _device, _clock.Now);
+
+ [Fact]
+ public async Task Stored_sides_are_isolated_and_survive_new_store_instance()
+ {
+ var left = await Assign("left", 1); var right = await Assign("right", 2);
+ await _repository.Save("a", _device, Report("\"HR\":[72,16,0,80,18,1]"));
+ User(1);
+ var current = await _service.Current(left.Id);
+ Assert.Equal(72d, current.Readings["heartRate"].Value);
+ Assert.Equal("unknown", current.Readings["presence"].Freshness);
+ Assert.Single(await _service.Bindings());
+ await ExpectRejection(() => _service.Current(right.Id));
+ await ExpectRejection(() => _service.History(right.Id, 0, 2000000));
+ _db.Ado.Close();
+ using var reopened = new SqlSugarClient(new ConnectionConfig { ConnectionString = "DataSource=" + _databasePath,
+ DbType = DbType.Sqlite, IsAutoCloseConnection = true, InitKeyType = InitKeyType.Attribute });
+ var freshRepository = new Ib43DataRepository(reopened);
+ Assert.Equal(80d, (await freshRepository.Latest(2, right.Id))["heartRate"].Value);
+ var rows = await _db.Queryable().Where(o => o.UserId == 1).ToListAsync();
+ Assert.DoesNotContain("right", rows[0].ReadingsJson);
+ Assert.DoesNotContain("80", rows[0].ReadingsJson);
+ }
+
+ [Fact]
+ public async Task Partial_updates_and_zero_are_distinct_and_each_field_has_own_age()
+ {
+ var bed = await Assign("left", 1);
+ await _repository.Save("a", _device, Report("\"SleepReportNew\":{\"ibNew\":[1,0,0,16,74,0,0,0,0,0,0,0]}"));
+ _clock.Now += TimeSpan.FromSeconds(151);
+ await _repository.Save("b", _device, Report("\"HR\":[0,0,0,80,18,0]"));
+ User(1);
+ var current = await _service.Current(bed.Id);
+ Assert.Null(current.Readings["heartRate"].Value);
+ Assert.Equal("recent", current.Readings["heartRate"].Freshness);
+ Assert.Equal(1d, current.Readings["presence"].Value);
+ Assert.Equal("stale", current.Readings["presence"].Freshness);
+ Assert.Equal(2, (await _service.History(bed.Id, 0, 2000000)).Items.Count);
+ }
+
+ [Fact]
+ public async Task Reassignment_does_not_transfer_previous_users_data_or_delayed_reports()
+ {
+ var oldBed = await Assign("left", 1);
+ var pending = Report("\"HR\":[72,16,0,80,18,0]");
+ _clock.Now += TimeSpan.FromSeconds(1);
+ Admin(); await _service.Release(oldBed.Id);
+ var newBed = await Assign("left", 3);
+ await _repository.Save("delayed", _device, pending);
+ User(3);
+ Assert.Equal("unknown", (await _service.Current(newBed.Id)).Readings["heartRate"].Freshness);
+ Assert.Empty((await _service.History(newBed.Id, 0, 2000000)).Items);
+ await ExpectRejection(() => _service.Current(oldBed.Id));
+ await _repository.Save("new", _device, Report("\"HR\":[90,20,0,0,0,0]"));
+ Assert.Equal(90d, (await _service.Current(newBed.Id)).Readings["heartRate"].Value);
+ User(1);
+ Assert.Equal(72d, (await _service.Current(oldBed.Id)).Readings["heartRate"].Value);
+ Assert.False((await _service.Current(oldBed.Id)).Active);
+ }
+
+ [Fact]
+ public async Task Retries_are_idempotent_and_history_cursor_does_not_skip_same_timestamp()
+ {
+ var bed = await Assign("left", 1);
+ var report = Report("\"HR\":[72,16,0,80,18,0]");
+ await _repository.Save("a", _device, report);
+ await _repository.Save("a", _device, report);
+ await _repository.Save("b", _device, report);
+ User(1);
+ var page = await _service.History(bed.Id, 0, 2000000, take: 1);
+ Assert.NotNull(page.NextBeforeId);
+ var next = await _service.History(bed.Id, 0, 2000000, page.NextBeforeId, 1);
+ Assert.Single(next.Items); Assert.Null(next.NextBeforeId);
+ Assert.NotEqual(page.Items[0].Id, next.Items[0].Id);
+ await ExpectRejection(() => _service.History(bed.Id, 0, 8L * 86400000));
+ await ExpectRejection(() => _service.History(bed.Id, 0, 2000000, take: 201));
+ }
+
+ [Fact]
+ public async Task Anonymous_disabled_and_health_users_cannot_provision_or_read_others()
+ {
+ await ExpectRejection(() => _service.Bindings());
+ var bed = await Assign("left", 1);
+ User(1);
+ await ExpectRejection(() => _service.Assign(new() { ProductKey = "test", SerialNumber = "sim", Side = "right", UserId = 1, BedName = "test" }));
+ await ExpectRejection(() => _service.Release(bed.Id));
+ await _db.Updateable().SetColumns(u => u.Status == HealthUserStatus.Disabled).Where(u => u.Id == 1).ExecuteCommandAsync();
+ await ExpectRejection(() => _service.Bindings());
+ await ExpectRejection(() => _service.Current(bed.Id));
+ }
+
+ [Fact]
+ public async Task Duplicate_active_slot_is_rejected_and_unbound_invalid_reports_are_not_saved()
+ {
+ await _repository.Save("unbound", _device, Report("\"HR\":[72,16,0,80,18,0]"));
+ Assert.Equal(0, await _db.Queryable().CountAsync());
+ await Assign("left", 1);
+ await ExpectRejection(() => Assign("left", 2));
+ await _repository.Save("bad", _device, Report("\"HR\":[72]"));
+ Assert.Equal(0, await _db.Queryable().CountAsync());
+ await Assign("right", 2);
+ }
+
+ private static async Task ExpectRejection(Func action)
+ {
+ var error = await Assert.ThrowsAnyAsync(action);
+ Assert.Equal("Furion.FriendlyException.AppFriendlyException", error.GetType().FullName);
+ }
+
+ [Fact]
+ public async Task Local_maintenance_previews_then_binds_with_audit_and_is_idempotent()
+ {
+ _db.CodeFirst.InitTables();
+ var options = new Ib43Options { Devices = [new() { ProductKey = "evergreen-test", SerialNumber = "IB43SIM001", Simulation = true }] };
+ var tool = new Ib43LocalBinding(_db, _clock);
+ await tool.Run("1", options, "test-machine/test-operator", false);
+ Assert.Equal(0, await _db.Queryable().CountAsync());
+ var first = await tool.Run("1", options, "test-machine/test-operator", true);
+ var repeat = await tool.Run("1", options, "test-machine/test-operator", true);
+ Assert.Equal(first.Id, repeat.Id);
+ Assert.Equal(0, first.AssignedBy);
+ Assert.Equal("left", first.Side);
+ var audit = await _db.Queryable().SingleAsync();
+ Assert.Equal(first.Id, audit.BindingId);
+ Assert.Equal("test-machine/test-operator", audit.Actor);
+ await Assert.ThrowsAsync(() => tool.Run("2", options, "test", true));
+ Assert.Single(await _db.Queryable().ToListAsync());
+ options.Devices[0].Simulation = false;
+ await Assert.ThrowsAsync(() => tool.Run("1", options, "test", true));
+ }
+
+ [Fact]
+ public async Task Local_maintenance_refuses_unknown_user_and_rolls_back_when_audit_fails()
+ {
+ var options = new Ib43Options { Devices = [new() { ProductKey = "evergreen-test", SerialNumber = "IB43SIM001", Simulation = true }] };
+ var tool = new Ib43LocalBinding(_db, _clock);
+ await Assert.ThrowsAsync(() => tool.Run("missing", options, "test", true));
+ // 故意不建审计表,验证绑定不会单独提交。
+ await Assert.ThrowsAsync(() => tool.Run("1", options, "test", true));
+ Assert.Equal(0, await _db.Queryable().CountAsync());
+ }
+
+ public void Dispose()
+ {
+ _db.Dispose();
+ Microsoft.Data.Sqlite.SqliteConnection.ClearAllPools();
+ File.Delete(_databasePath);
+ }
+}
diff --git a/Admin.NET.Test/Health/Ib43Tests.cs b/Admin.NET.Test/Health/Ib43Tests.cs
new file mode 100644
index 0000000..70fb680
--- /dev/null
+++ b/Admin.NET.Test/Health/Ib43Tests.cs
@@ -0,0 +1,205 @@
+using Evergreen.Health.Application.Health.Integrations.Ib43;
+using Microsoft.Extensions.Logging.Abstractions;
+using Microsoft.Extensions.Options;
+using System.Text.Json;
+using Xunit;
+
+namespace Admin.NET.Test.Health;
+
+public sealed class Ib43Tests
+{
+ private static Ib43Binding Binding() => new() { ProductKey = "evergreen-test", SerialNumber = "IB43SIM001" };
+ private static string Report(string fields) => "{\"method\":\"thing.property.post\",\"params\":{\"deviceName\":\"IB43SIM001\"," + fields + "}}";
+ private static Ib43Report Parse(string fields) => new Ib43Parser().Parse(Binding().PostTopic, Report(fields), Binding(), DateTimeOffset.UtcNow);
+ private sealed class Clock : TimeProvider
+ {
+ public DateTimeOffset Now = DateTimeOffset.UtcNow;
+ public override DateTimeOffset GetUtcNow() => Now;
+ }
+ private static Ib43StateStore Store(Clock clock) => new(Options.Create(new Ib43Options { Devices = [Binding()] }), new(), clock);
+
+ [Theory]
+ [InlineData("startup.pdf-example.json", "startup")]
+ [InlineData("realtime.pdf-example.json", "periodic")]
+ [InlineData("sleep-event.pdf-keys.json", "event")]
+ public void Archived_supplier_examples_parse_without_issues(string file, string kind)
+ {
+ var json = File.ReadAllText(Path.Combine(AppContext.BaseDirectory, "Ib43Examples", file));
+ using var doc = JsonDocument.Parse(json);
+ var p = doc.RootElement.GetProperty("params");
+ var binding = new Ib43Binding { SerialNumber = p.GetProperty("deviceName").GetString(),
+ ProductKey = p.TryGetProperty("productKey", out var pk) ? pk.GetString() : "example" };
+ var report = new Ib43Parser().Parse(binding.PostTopic, json, binding, DateTimeOffset.UtcNow);
+ Assert.True(report.Accepted);
+ Assert.Empty(report.Issues);
+ Assert.Contains(kind, report.Kinds);
+ if (kind == "startup") Assert.Equal(60, report.Fields["characteristic"].GetProperty("reportIerVal").GetInt32());
+ if (kind == "periodic") Assert.Equal(-52, report.Fields["airbagsPressure"][3].GetInt32());
+ if (kind == "event") Assert.Equal(80d, report.Readings["right.heartRate"].Value);
+ }
+
+ [Fact]
+ public void Periodic_HR_uses_confirmed_order_and_does_not_infer_presence()
+ {
+ var r = Parse("\"HR\":[72,16,1,80,18,0],\"snoreStatus\":[3,9],\"airbagsPerson\":[1,0]");
+ Assert.Equal(72d, r.Readings["left.heartRate"].Value);
+ Assert.Equal(18d, r.Readings["right.respiration"].Value);
+ Assert.Equal(1d, r.Readings["left.snoring"].Value);
+ Assert.Equal(0d, r.Readings["right.snoring"].Value);
+ Assert.DoesNotContain("left.presence", r.Readings.Keys);
+ }
+
+ [Theory]
+ [InlineData("SleepReportNew", "ibNew")]
+ [InlineData(" SleepReportNew", " ibNew")]
+ [InlineData(" SleepReportNew", "ibNew")]
+ public void Event_has_its_own_order_and_preserves_sides(string outer, string inner)
+ {
+ var r = Parse($"\"{outer}\":{{\"{inner}\":[1,0,0,16,74,0,0,1,0,17,80,0]}}");
+ Assert.Equal(74d, r.Readings["left.heartRate"].Value);
+ Assert.Equal(17d, r.Readings["right.respiration"].Value);
+ Assert.Equal(1d, r.Readings["left.presence"].Value);
+ Assert.Equal(0d, r.Readings["right.presence"].Value);
+ }
+
+ [Fact]
+ public void Partial_updates_preserve_presence_but_zero_clears_previous_vitals()
+ {
+ var clock = new Clock(); var store = Store(clock);
+ store.Receive(Binding().PostTopic, Report("\"SleepReportNew\":{\"ibNew\":[1,0,0,16,74,0,0,0,0,0,0,0]}"));
+ var first = store.Snapshot()[0].Readings["left.presence"].ReceivedAt;
+ clock.Now += TimeSpan.FromSeconds(5);
+ store.Receive(Binding().PostTopic, Report("\"HR\":[0,0,0,80,18,1]"));
+ var state = store.Snapshot()[0];
+ Assert.Equal(1d, state.Readings["left.presence"].Value);
+ Assert.Equal(first, state.Readings["left.presence"].ReceivedAt);
+ Assert.Null(state.Readings["left.heartRate"].Value);
+ Assert.Equal(80d, state.Readings["right.heartRate"].Value);
+ }
+
+ [Theory]
+ [InlineData("\"HR\":[1,2]", "HR:invalid-length")]
+ [InlineData("\"HR\":[72,16,0,80,\"18\",0]", "HR:invalid-number")]
+ [InlineData("\"HR\":[72,16,4,80,18,0]", "left.snoring:invalid-enum")]
+ [InlineData("\"motorStatus\":[0,0,0,9]", "motorStatus:invalid-enum")]
+ [InlineData("\"SleepReportNew\":{},\" SleepReportNew\":{}", "SleepReportNew:ambiguous-alias")]
+ [InlineData("\"SleepReportNew\":{\"ibNew\":[],\" ibNew\":[]}", "ibNew:ambiguous-alias")]
+ public void Malformed_fields_record_issues(string fields, string issue)
+ => Assert.Contains(issue, Parse(fields).Issues);
+
+ [Theory]
+ [InlineData("{", "invalid-json")]
+ [InlineData("[]", "invalid-envelope")]
+ [InlineData("{\"method\":\"x\",\"params\":{}}", "invalid-envelope")]
+ [InlineData("{\"method\":\"thing.property.post\",\"params\":{\"deviceName\":\"other\"}}", "identity-mismatch")]
+ [InlineData("{\"method\":\"thing.property.post\",\"params\":{\"deviceName\":\"IB43SIM001\",\"productKey\":\"other\"}}", "identity-mismatch")]
+ [InlineData("{\"method\":\"thing.property.post\",\"params\":{\"deviceName\":\"IB43SIM001\",\"HR\":[],\"HR\":[]}}", "duplicate-json-key")]
+ public void Rejects_invalid_envelopes_and_identity(string raw, string issue)
+ {
+ var r = new Ib43Parser().Parse(Binding().PostTopic, raw, Binding(), DateTimeOffset.UtcNow);
+ Assert.False(r.Accepted); Assert.Contains(issue, r.Issues); Assert.Empty(r.Readings);
+ }
+
+ [Fact]
+ public void Old_events_are_not_erased_by_timeout_and_transport_is_independent()
+ {
+ var clock = new Clock(); var store = Store(clock);
+ Assert.Equal("unknown", store.Snapshot()[0].Freshness);
+ store.SetTransport("connected");
+ store.Receive(Binding().PostTopic, Report("\"SleepReportNew\":{\"ibNew\":[1,0,0,16,74,0,0,0,0,0,0,0]}"));
+ clock.Now += TimeSpan.FromSeconds(151);
+ store.Receive(Binding().PostTopic, Report("\"HR\":[0]"));
+ store.Receive(Binding().PostTopic, Report("\"firmwareVer\":\"new\""));
+ var snapshot = store.Snapshot()[0];
+ Assert.Equal("stale", snapshot.Freshness);
+ Assert.Equal("connected", snapshot.Transport);
+ Assert.Equal(1d, snapshot.Readings["left.presence"].Value);
+ store.SetTransport("disconnected");
+ Assert.Equal("disconnected", store.Snapshot()[0].Transport);
+ }
+
+ [Fact]
+ public void Unbound_retained_and_oversized_reports_never_update_live_state_and_history_is_bounded()
+ {
+ var store = Store(new()); var payload = Report("\"HR\":[72,16,0,80,18,0]");
+ Assert.False(store.Receive("/sys/other/IB43SIM001/thing/property/post", payload).Accepted);
+ Assert.False(store.Receive(Binding().PostTopic, payload, true).Accepted);
+ Assert.False(store.Receive(Binding().PostTopic, new string('x', 65537)).Accepted);
+ Assert.Equal("unknown", store.Snapshot()[0].Freshness);
+ for (var i = 0; i < 100; i++) store.Receive(Binding().PostTopic, "bad-json");
+ Assert.Equal(32, store.Diagnostics().Length);
+ Assert.Equal("bad-json", store.Diagnostics()[0].Raw);
+ }
+
+ [Theory]
+ [InlineData(0, 1, 0)]
+ [InlineData(1, 0, 1)]
+ public void Presence_mapping_requires_explicit_configuration(int index, int left, int right)
+ {
+ var binding = Binding(); binding.PersonLeftIndex = index;
+ var r = new Ib43Parser().Parse(binding.PostTopic, Report("\"airbagsPerson\":[1,0]"), binding, DateTimeOffset.UtcNow);
+ Assert.Equal((double)left, r.Readings["left.presence"].Value);
+ Assert.Equal((double)right, r.Readings["right.presence"].Value);
+ }
+
+ [Fact]
+ public void Enabled_configuration_requires_credentials_and_exact_unique_topics()
+ {
+ var options = new Ib43Options(); Assert.True(options.IsValid());
+ options.Enabled = true; Assert.False(options.IsValid());
+ options.Password = "test-only"; options.Devices = [Binding()]; Assert.True(options.IsValid());
+ options.Devices.Add(Binding()); Assert.False(options.IsValid());
+ options.Devices = [new() { ProductKey = "+", SerialNumber = "#" }]; Assert.False(options.IsValid());
+ }
+
+ [Fact]
+ public async Task Disabled_service_never_creates_connection()
+ {
+ var factory = new FakeFactory();
+ using var service = new Ib43MqttService(Options.Create(new Ib43Options()), factory, Store(new()),
+ TimeProvider.System, NullLogger.Instance);
+ await service.StartAsync(default); await service.StopAsync(default);
+ Assert.Equal(0, factory.Count);
+ }
+
+ [Fact]
+ public async Task Service_retries_failed_subscription_then_receives_and_stops()
+ {
+ var factory = new FakeFactory(); var store = Store(new());
+ using var service = new Ib43MqttService(Options.Create(new Ib43Options { Enabled = true,
+ Password = "test-only", Devices = [Binding()] }), factory, store,
+ TimeProvider.System, NullLogger.Instance);
+ await service.StartAsync(default);
+ try
+ {
+ await factory.Connected.Task.WaitAsync(TimeSpan.FromSeconds(5));
+ Assert.Equal(2, factory.Count);
+ Assert.Equal(72d, store.Snapshot()[0].Readings["left.heartRate"].Value);
+ }
+ finally { await service.StopAsync(default); }
+ Assert.Equal("disconnected", store.Snapshot()[0].Transport);
+ Assert.True(factory.Last.Disposed);
+ }
+
+ private sealed class FakeFactory : IIb43MqttTransportFactory
+ {
+ public int Count;
+ public FakeTransport Last;
+ public TaskCompletionSource Connected = new(TaskCreationOptions.RunContinuationsAsynchronously);
+ public IIb43MqttTransport Create() => Last = new FakeTransport(++Count == 1, Connected);
+ }
+ private sealed class FakeTransport(bool fail, TaskCompletionSource connected) : IIb43MqttTransport
+ {
+ public bool Disposed;
+ public bool IsConnected => !Disposed;
+ public event Func Message;
+ public event Action OversizedMessage { add { } remove { } }
+ public async Task ConnectAndSubscribeAsync(Ib43Options options, CancellationToken cancellationToken)
+ {
+ if (fail) throw new InvalidOperationException("subscription denied");
+ if (Message != null) await Message.Invoke(options.Devices[0].PostTopic, Report("\"HR\":[72,16,0,80,18,0]"), false);
+ connected.TrySetResult();
+ }
+ public void Dispose() => Disposed = true;
+ }
+}
diff --git a/Admin.NET.Test/Health/OutletIntegrationTests.cs b/Admin.NET.Test/Health/OutletIntegrationTests.cs
index 29026bb..4e3dce3 100644
--- a/Admin.NET.Test/Health/OutletIntegrationTests.cs
+++ b/Admin.NET.Test/Health/OutletIntegrationTests.cs
@@ -162,6 +162,7 @@ public sealed class OutletIntegrationTests
{
var services = new ServiceCollection();
services.AddLogging(); services.AddOptions(); services.AddHttpClient(); services.AddHealthDevices();
+ services.AddSingleton(Settings());
using var provider = services.BuildServiceProvider(new ServiceProviderOptions { ValidateOnBuild = true, ValidateScopes = true });
var module = provider.GetRequiredService();
Assert.Same(module, provider.GetRequiredService().Modules.Single(item => item.Key == "outlet"));
diff --git a/Admin.NET.Web.Core/Startup.cs b/Admin.NET.Web.Core/Startup.cs
index 462d423..612ae56 100644
--- a/Admin.NET.Web.Core/Startup.cs
+++ b/Admin.NET.Web.Core/Startup.cs
@@ -11,6 +11,8 @@ using AspNetCoreRateLimit;
using Evergreen.Health.Application.Health.Integrations.HomeAssistant;
using Evergreen.Health.Application.Health.Devices;
using Evergreen.Health.Application.Health.Integrations.MiAirX;
+using Evergreen.Health.Application.Health.Integrations.Ib43;
+using Evergreen.Health.Application.Health.Sleep;
using Furion;
using Furion.Logging;
using Furion.SpecificationDocument;
@@ -24,6 +26,7 @@ using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.HttpOverrides;
using Microsoft.AspNetCore.ResponseCompression;
using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Newtonsoft.Json;
@@ -82,6 +85,12 @@ public class Startup : AppStartup
services.AddHttpClient();
services.AddHttpClient();
services.AddHealthDevices();
+ services.AddOptions().BindConfiguration("Ib43")
+ .PostConfigure(Ib43Configuration.ApplyEnvironmentOverrides)
+ .Validate(options => options.IsValid(), "IB43 enabled configuration requires credentials and valid unique device bindings")
+ .ValidateOnStart();
+ services.AddOptions().BindConfiguration("BedPresenceAutomation")
+ .PostConfigure(options => new ConfigurationBuilder().AddEnvironmentVariables("BedPresenceAutomation__").Build().Bind(options));
// 任务队列
services.AddTaskQueue();
// 脱敏检测
diff --git a/Evergreen.Health.Application/Configuration/BedPresenceAutomation.json b/Evergreen.Health.Application/Configuration/BedPresenceAutomation.json
new file mode 100644
index 0000000..ad010b4
--- /dev/null
+++ b/Evergreen.Health.Application/Configuration/BedPresenceAutomation.json
@@ -0,0 +1,11 @@
+{
+ "BedPresenceAutomation": {
+ "Mode": "Disabled",
+ "BindingId": "",
+ "ProductKey": "evergreen-test",
+ "SerialNumber": "IB43SIM001",
+ "Side": "Left",
+ "ActionKey": "curtain-close",
+ "TargetEntityId": "cover.czmydz_cn_2251706154_mym1_s_2_curtain"
+ }
+}
diff --git a/Evergreen.Health.Application/Configuration/HomeAssistant.json b/Evergreen.Health.Application/Configuration/HomeAssistant.json
index df6a95b..b830aad 100644
--- a/Evergreen.Health.Application/Configuration/HomeAssistant.json
+++ b/Evergreen.Health.Application/Configuration/HomeAssistant.json
@@ -15,12 +15,10 @@
"FragranceAutoRelease": "switch.xiaomi_cn_1206553658_xw002_auto_fragrance_p_4_1",
"FragranceAutoLight": "switch.xiaomi_cn_1206553658_xw002_auto_light_p_4_2",
"FragranceLightAutoOff": "switch.xiaomi_cn_1206553658_xw002_light_auto_off_p_4_3",
- "AmbientLight": "light.lemesh_cn_2041198316_wy0d02_s_2_light",
"Speaker": "media_player.xiaomi_cn_1221084723_l17a",
"SpeakerSleepMode": "switch.xiaomi_cn_1221084723_l17a_sleep_mode_p_7_3",
"Climate": "climate.fawad_cn_974720212_3010",
"Curtain": "cover.czmydz_cn_2251706154_mym1_s_2_curtain",
- "Outlet": "switch.ym001_cn_2108613283_ymcz_on_p_2_1",
"Temperature": "sensor.mirsz_cn_2027039858_et71_temperature_p_3_1",
"Humidity": "sensor.mirsz_cn_2027039858_et71_relative_humidity_p_3_2",
"Pm25": "sensor.mirsz_cn_2027039858_et71_pm2_5_density_p_3_3",
@@ -28,20 +26,18 @@
"Formaldehyde": "sensor.mirsz_cn_2027039858_et71_hcho_density_p_3_7",
"Tvoc": "sensor.mirsz_cn_2027039858_et71_tvoc_density_p_3_8"
},
- "AdditionalSleepLights": [
+ "OnlyConfiguredDevices": true,
+ "DisplayOnlyDevices": [
{
- "Key": "sleep-light-strip",
- "Name": "烛照智能灯带Ultra2.0",
- "EntityId": "light.zhunen_cn_2205208916_wy0a01_s_2_light",
- "SleepEffect": "助眠模式"
+ "Name": "香氛机 唤醒",
+ "EntityId": "switch.xiaomi_cn_1238447032_xw002_on_p_2_2",
+ "Category": "Fragrance"
}
],
"SleepScene": {
"TargetHumidity": 55,
"DehumidifierMode": "睡眠",
"FanLevel": "1挡",
- "LightBrightnessPercent": 20,
- "LightColorTemperatureKelvin": 2700,
"EnableFragrance": true,
"EnableSpeakerSleepMode": true,
"EnableUv": false
diff --git a/Evergreen.Health.Application/Configuration/Ib43.json b/Evergreen.Health.Application/Configuration/Ib43.json
new file mode 100644
index 0000000..5631a98
--- /dev/null
+++ b/Evergreen.Health.Application/Configuration/Ib43.json
@@ -0,0 +1,20 @@
+{
+ "Ib43": {
+ "Enabled": false,
+ "Host": "192.168.2.2",
+ "Port": 1883,
+ "UseTls": false,
+ "Username": "evergreen-backend",
+ "Password": "",
+ "ClientId": "evergreen-health-backend",
+ "StaleAfterSeconds": 150,
+ "Devices": [
+ {
+ "ProductKey": "evergreen-test",
+ "SerialNumber": "IB43SIM001",
+ "Simulation": true,
+ "PersonLeftIndex": null
+ }
+ ]
+ }
+}
diff --git a/Evergreen.Health.Application/Evergreen.Health.Application.csproj b/Evergreen.Health.Application/Evergreen.Health.Application.csproj
index a5ed9a6..54e770c 100644
--- a/Evergreen.Health.Application/Evergreen.Health.Application.csproj
+++ b/Evergreen.Health.Application/Evergreen.Health.Application.csproj
@@ -25,6 +25,7 @@
+
diff --git a/Evergreen.Health.Application/Health/Devices/Curtain/BedPresenceCurtainCloseAction.cs b/Evergreen.Health.Application/Health/Devices/Curtain/BedPresenceCurtainCloseAction.cs
new file mode 100644
index 0000000..ef80d2a
--- /dev/null
+++ b/Evergreen.Health.Application/Health/Devices/Curtain/BedPresenceCurtainCloseAction.cs
@@ -0,0 +1,48 @@
+using Evergreen.Health.Application.Health.Integrations.HomeAssistant;
+using Evergreen.Health.Application.Health.Scenes;
+using Evergreen.Health.Application.Health.Sleep;
+using Microsoft.Extensions.Options;
+
+namespace Evergreen.Health.Application.Health.Devices.Curtain;
+
+public sealed class BedPresenceCurtainCloseAction(CurtainModule curtain, DeviceActionRunner runner,
+ IOptions options) : IBedPresenceAction
+{
+ public string Key => "curtain-close";
+ public string Description => "关闭窗帘";
+ public TimeSpan ExecutionTimeout => TimeSpan.FromSeconds(55);
+
+ public bool SupportsTarget(string entityId) => options.Value.Enabled &&
+ entityId?.StartsWith("cover.", StringComparison.Ordinal) == true && entityId == curtain.PrimaryEntityId;
+
+ public async Task ExecuteAsync(string entityId, CancellationToken token)
+ {
+ if (!SupportsTarget(entityId)) return "target-not-configured";
+ var state = await runner.ReadAvailableStateAsync(entityId, token);
+ if (state.State == "closed") return "already-closed";
+ if (state.State == "closing") return "already-closing";
+ if (state.State != "open") return "state-not-open";
+
+ var close = curtain.BuildParameterCommand("curtain_action", "close");
+ var command = close with
+ {
+ ExpectedState = "closed",
+ ValidateCurrentState = current =>
+ {
+ close.ValidateCurrentState?.Invoke(current);
+ if (current.State != "open") throw new InvalidOperationException("窗帘状态已改变,跳过关帘");
+ }
+ };
+ await runner.SendAsync(command, token);
+ try
+ {
+ // 机械行程需要较长回读时间。沿用窗帘命令的只读回读配置,失败不重发关帘。
+ await runner.VerifyParameterAsync(command, token, verificationAttempts: 20);
+ return "readback-confirmed";
+ }
+ catch (InvalidOperationException)
+ {
+ return "sent-readback-unconfirmed";
+ }
+ }
+}
diff --git a/Evergreen.Health.Application/Health/Devices/Curtain/CurtainModule.cs b/Evergreen.Health.Application/Health/Devices/Curtain/CurtainModule.cs
index 7483bb8..8065dc8 100644
--- a/Evergreen.Health.Application/Health/Devices/Curtain/CurtainModule.cs
+++ b/Evergreen.Health.Application/Health/Devices/Curtain/CurtainModule.cs
@@ -6,7 +6,7 @@ using Microsoft.Extensions.Options;
namespace Evergreen.Health.Application.Health.Devices.Curtain;
-/// 窗帘开合不是电源开关;本版只提供单独手动控制,不擅自加入舒睡/全关。
+/// 集中定义窗帘开合动作和能力校验,供手动控制与独立联动复用。
public sealed class CurtainModule : HealthDeviceModule
{
private readonly string _entityId;
@@ -70,7 +70,7 @@ public sealed class CurtainModule : HealthDeviceModule
{
cancellationToken.ThrowIfCancellationRequested();
context.Results.Add(new() { Name = "窗帘场景联动", EntityId = _entityId, Status = "Skipped",
- Message = string.IsNullOrWhiteSpace(_entityId) ? "窗帘实体尚未配置" : "本版窗帘只支持手动控制,舒睡不自动改变开合位置" });
+ Message = string.IsNullOrWhiteSpace(_entityId) ? "窗帘实体尚未配置" : "一键舒睡保持窗帘当前位置" });
return Task.CompletedTask;
}
}
diff --git a/Evergreen.Health.Application/Health/Devices/Discovery/HomeAssistantDeviceCatalog.cs b/Evergreen.Health.Application/Health/Devices/Discovery/HomeAssistantDeviceCatalog.cs
index f2f29fc..89928b6 100644
--- a/Evergreen.Health.Application/Health/Devices/Discovery/HomeAssistantDeviceCatalog.cs
+++ b/Evergreen.Health.Application/Health/Devices/Discovery/HomeAssistantDeviceCatalog.cs
@@ -39,6 +39,10 @@ public sealed class HomeAssistantDeviceCatalog
{
var regEntities = entitiesByDevice.GetValueOrDefault(device.Id) ?? [];
if (regEntities.Count == 0) continue;
+ var entityIds = regEntities.Select(entity => entity.EntityId).ToHashSet(StringComparer.OrdinalIgnoreCase);
+ var module = _modules.ForEntities(entityIds);
+ var displayOnly = _options.DisplayOnlyDevices.FirstOrDefault(item => entityIds.Contains(item.EntityId));
+ if (_options.OnlyConfiguredDevices && module is null && displayOnly is null) continue;
var entityOutputs = new List();
foreach (var regEntity in regEntities)
@@ -76,10 +80,9 @@ public sealed class HomeAssistantDeviceCatalog
? an
: null;
- var module = _modules.ForEntities(entityOutputs.Select(entity => entity.EntityId));
- var category = module?.Category ?? CategorizeDevice(displayName, device.Model ?? "", device.Manufacturer ?? "", entityOutputs);
+ var category = module?.Category ?? displayOnly?.Category ?? CategorizeDevice(displayName, device.Model ?? "", device.Manufacturer ?? "", entityOutputs);
var metrics = ExtractDeviceMetrics(category, entityOutputs);
- var primaryControlEntityId = FindPrimaryControlEntityId(category, entityOutputs);
+ var primaryControlEntityId = module is null && displayOnly is not null ? null : FindPrimaryControlEntityId(category, entityOutputs);
discovered.Add(new HomeAssistantDiscoveredDeviceOutput
{
@@ -134,6 +137,20 @@ public sealed class HomeAssistantDeviceCatalog
Entities = entityOutputs, Metrics = ExtractDeviceMetrics(module.Category, entityOutputs)
});
}
+ foreach (var device in _options.DisplayOnlyDevices)
+ {
+ if (string.IsNullOrWhiteSpace(device.EntityId) || _modules.ForEntities([device.EntityId]) is not null ||
+ _options.ExcludedDeviceEntityIds.Contains(device.EntityId, StringComparer.OrdinalIgnoreCase)) continue;
+ stateMap.TryGetValue(device.EntityId, out var state);
+ var available = state is not null && IsAvailable(state);
+ list.Add(new()
+ {
+ DeviceId = $"fallback_{device.EntityId}", Name = device.Name, Category = device.Category,
+ Online = available, Entities = [new() { EntityId = device.EntityId, Domain = device.EntityId.Split('.')[0],
+ Name = state?.GetStringAttribute("friendly_name") ?? device.Name, State = state?.State ?? "unavailable",
+ Available = available, Attributes = ExtractSafeAttributes(state) }]
+ });
+ }
return list;
}
diff --git a/Evergreen.Health.Application/Health/Devices/HealthDeviceRegistration.cs b/Evergreen.Health.Application/Health/Devices/HealthDeviceRegistration.cs
index c319a53..6d0e2f7 100644
--- a/Evergreen.Health.Application/Health/Devices/HealthDeviceRegistration.cs
+++ b/Evergreen.Health.Application/Health/Devices/HealthDeviceRegistration.cs
@@ -15,6 +15,7 @@ using Evergreen.Health.Application.Health.Sleep;
using Evergreen.Health.Application.Health.Integrations.Tuya;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
+using Evergreen.Health.Application.Health.Integrations.Ib43;
namespace Evergreen.Health.Application.Health.Devices;
@@ -23,6 +24,20 @@ public static class HealthDeviceRegistration
public static IServiceCollection AddHealthDevices(this IServiceCollection services)
{
services.AddSingleton(TimeProvider.System);
+ services.AddOptions();
+ services.AddSingleton();
+ services.AddSingleton();
+ services.AddScoped(provider => new Ib43DataRepository(
+ provider.GetRequiredService().AsTenant().GetConnectionScope(SqlSugarConst.MainConfigId)));
+ services.AddSingleton();
+ services.AddSingleton();
+ services.AddOptions();
+ services.AddSingleton();
+ services.AddSingleton();
+ services.AddSingleton();
+ services.AddSingleton(provider => provider.GetRequiredService());
+ services.AddHostedService(provider => provider.GetRequiredService());
+ services.AddHostedService();
services.AddSingleton(new DeviceExecutionOptions());
// 仅注册扩展契约,不启动传感器轮询、上床流程或占位音频播放。
services.AddSingleton(new SleepWorkflowOptions());
@@ -49,7 +64,6 @@ public static class HealthDeviceRegistration
services.AddSingleton();
services.AddSingleton(provider => provider.GetRequiredService());
services.AddSingleton();
- services.AddSingleton(provider => provider.GetRequiredService());
services.AddSingleton();
services.AddSingleton(provider => provider.GetRequiredService());
services.AddHostedService(provider => provider.GetRequiredService());
@@ -65,15 +79,20 @@ public static class HealthDeviceRegistration
provider.GetRequiredService().ForEntity(
provider.GetRequiredService>().Value.Entities.AmbientLight),
provider.GetRequiredService()));
- services.AddSingleton(provider => provider.GetRequiredService());
services.AddSingleton();
services.AddSingleton(provider => provider.GetRequiredService());
services.AddSingleton();
- services.AddSingleton(provider => new(
- provider.GetServices().Concat(
- provider.GetRequiredService>().Value.AdditionalSleepLights.Select(light =>
- new ConfiguredSleepLightModule(provider.GetRequiredService(), light,
- provider.GetRequiredService().ForEntity(light.EntityId))))));
+ services.AddSingleton(provider =>
+ {
+ var options = provider.GetRequiredService>().Value;
+ var modules = provider.GetServices().Where(module => module.EntityIds.Any()).ToList();
+ if (!string.IsNullOrWhiteSpace(options.Entities.Outlet)) modules.Add(provider.GetRequiredService());
+ if (!string.IsNullOrWhiteSpace(options.Entities.AmbientLight)) modules.Add(provider.GetRequiredService());
+ var lights = provider.GetRequiredService();
+ var runner = provider.GetRequiredService();
+ modules.AddRange(options.AdditionalSleepLights.Select(light => new ConfiguredSleepLightModule(runner, light, lights.ForEntity(light.EntityId))));
+ return new DeviceModuleRegistry(modules);
+ });
services.AddSingleton();
services.AddSingleton();
services.AddSingleton();
diff --git a/Evergreen.Health.Application/Health/Devices/Lighting/BedPresenceSleepLightAction.cs b/Evergreen.Health.Application/Health/Devices/Lighting/BedPresenceSleepLightAction.cs
new file mode 100644
index 0000000..3e3f723
--- /dev/null
+++ b/Evergreen.Health.Application/Health/Devices/Lighting/BedPresenceSleepLightAction.cs
@@ -0,0 +1,37 @@
+using Evergreen.Health.Application.Health.Integrations.HomeAssistant;
+using Evergreen.Health.Application.Health.Scenes;
+using Evergreen.Health.Application.Health.Sleep;
+using Microsoft.Extensions.Options;
+
+namespace Evergreen.Health.Application.Health.Devices.Lighting;
+
+public sealed class BedPresenceSleepLightAction(HomeAssistantLightTransitionService lights, DeviceActionRunner runner,
+ DeviceExecutionOptions execution, IOptions options) : IBedPresenceAction
+{
+ public string Key => "light-sleep";
+ public string Description => "启动助眠渐暗";
+
+ public bool SupportsTarget(string entityId) => options.Value.Enabled && entityId?.StartsWith("light.", StringComparison.Ordinal) == true &&
+ (entityId == options.Value.Entities.AmbientLight || options.Value.AdditionalSleepLights.Any(light => light.EntityId == entityId));
+
+ public async Task ExecuteAsync(string entityId, CancellationToken token)
+ {
+ if (!SupportsTarget(entityId)) return "target-not-configured";
+ var transition = await lights.StartSleepIfIdleAsync(entityId, options.Value.SleepScene.LightColorTemperatureKelvin, token);
+ if (transition == null) return "already-running";
+ for (var attempt = 0; attempt < execution.VerificationAttempts; attempt++)
+ {
+ await runner.DelayAsync(execution.VerificationDelay, token);
+ try
+ {
+ var state = await runner.ReadAvailableStateAsync(entityId, token);
+ var brightness = state.GetDoubleAttribute("brightness");
+ if (state.State == "on" && brightness is double value &&
+ Math.Abs(value / 255 * 100 - transition.BrightnessPercent) <= 1.5)
+ return "readback-confirmed";
+ }
+ catch (InvalidOperationException) { /* 实体暂未回报,下一轮只重读状态。 */ }
+ }
+ return "sent-readback-unconfirmed";
+ }
+}
diff --git a/Evergreen.Health.Application/Health/Entities/HealthIb43Entities.cs b/Evergreen.Health.Application/Health/Entities/HealthIb43Entities.cs
new file mode 100644
index 0000000..4666b47
--- /dev/null
+++ b/Evergreen.Health.Application/Health/Entities/HealthIb43Entities.cs
@@ -0,0 +1,55 @@
+using SqlSugar;
+
+namespace Evergreen.Health.Application.Health.Entities;
+
+[SugarTable("health_ib43_binding", "IB43床位用户绑定历史")]
+[SugarIndex("ux_ib43_active_slot", nameof(ActiveSlot), OrderByType.Asc, IsUnique = true)]
+[SugarIndex("ix_ib43_binding_user", nameof(UserId), OrderByType.Asc)]
+public sealed class HealthIb43Binding
+{
+ [SugarColumn(IsPrimaryKey = true, Length = 32)]
+ public string Id { get; set; } = Guid.NewGuid().ToString("N");
+ [SugarColumn(Length = 64)]
+ public string DeviceKey { get; set; } = "";
+ [SugarColumn(Length = 128)]
+ public string ProductKey { get; set; } = "";
+ [SugarColumn(Length = 128)]
+ public string SerialNumber { get; set; } = "";
+ [SugarColumn(Length = 5)]
+ public string Side { get; set; } = "";
+ // 活跃时为设备哈希+侧别,解绑时为 NULL。数据库唯一索引防止并发抢占。
+ [SugarColumn(Length = 70, IsNullable = true)]
+ public string ActiveSlot { get; set; }
+ public long UserId { get; set; }
+ [SugarColumn(Length = 80)]
+ public string BedName { get; set; } = "";
+ public bool Simulation { get; set; }
+ public long StartedAtMs { get; set; }
+ [SugarColumn(IsNullable = true)]
+ public long? EndedAtMs { get; set; }
+ public long AssignedBy { get; set; }
+ [SugarColumn(IsNullable = true)]
+ public long? ReleasedBy { get; set; }
+}
+
+[SugarTable("health_ib43_observation", "IB43按用户侧别保存的观测增量")]
+[SugarIndex("ux_ib43_report_binding", nameof(ReportId), OrderByType.Asc, nameof(BindingId), OrderByType.Asc, IsUnique = true)]
+[SugarIndex("ix_ib43_obs_owner_time", nameof(UserId), OrderByType.Asc, nameof(BindingId), OrderByType.Asc, nameof(ReceivedAtMs), OrderByType.Asc)]
+public sealed class HealthIb43Observation
+{
+ [SugarColumn(IsPrimaryKey = true, IsIdentity = true)]
+ public long Id { get; set; }
+ [SugarColumn(Length = 32)]
+ public string ReportId { get; set; } = "";
+ [SugarColumn(Length = 32)]
+ public string BindingId { get; set; } = "";
+ public long UserId { get; set; }
+ public long ReceivedAtMs { get; set; }
+ public bool HasHeartRate { get; set; }
+ public bool HasRespiration { get; set; }
+ public bool HasPresence { get; set; }
+ public bool HasSnoring { get; set; }
+ // 只含该侧有效解析字段及来源,不含另一侧、原始完整报文或凭据。
+ [SugarColumn(ColumnDataType = "text")]
+ public string ReadingsJson { get; set; } = "{}";
+}
diff --git a/Evergreen.Health.Application/Health/Entities/HealthIb43MaintenanceAudit.cs b/Evergreen.Health.Application/Health/Entities/HealthIb43MaintenanceAudit.cs
new file mode 100644
index 0000000..6ca6115
--- /dev/null
+++ b/Evergreen.Health.Application/Health/Entities/HealthIb43MaintenanceAudit.cs
@@ -0,0 +1,14 @@
+using SqlSugar;
+
+namespace Evergreen.Health.Application.Health.Entities;
+
+[SugarTable("health_ib43_maintenance_audit", "IB43本地维护审计")]
+public sealed class HealthIb43MaintenanceAudit
+{
+ [SugarColumn(IsPrimaryKey = true, Length = 32)] public string Id { get; set; }
+ [SugarColumn(Length = 32)] public string BindingId { get; set; }
+ public long UserId { get; set; }
+ [SugarColumn(Length = 200)] public string Actor { get; set; }
+ [SugarColumn(Length = 64)] public string Operation { get; set; }
+ public long OccurredAtMs { get; set; }
+}
diff --git a/Evergreen.Health.Application/Health/Integrations/HomeAssistant/HomeAssistantLightTransitionService.cs b/Evergreen.Health.Application/Health/Integrations/HomeAssistant/HomeAssistantLightTransitionService.cs
index 8a4bd6b..7de84e5 100644
--- a/Evergreen.Health.Application/Health/Integrations/HomeAssistant/HomeAssistantLightTransitionService.cs
+++ b/Evergreen.Health.Application/Health/Integrations/HomeAssistant/HomeAssistantLightTransitionService.cs
@@ -30,8 +30,13 @@ public sealed class HomeAssistantLightTransitionService : IHostedService, ILight
}
public ILightTransitionRuntime ForEntity(string entityId)
- => _lights.TryGetValue(entityId, out var light) ? light
- : entityId == _primary.GetStatus().EntityId ? _primary
+ => ResolveLight(entityId);
+
+ public Task StartSleepIfIdleAsync(string entityId, int kelvin, CancellationToken token)
+ => ResolveLight(entityId).StartSleepIfIdleAsync(kelvin, token);
+
+ private LightTransitionWorker ResolveLight(string entityId)
+ => !string.IsNullOrWhiteSpace(entityId) && _lights.TryGetValue(entityId, out var light) ? light
: throw new InvalidOperationException("该灯未配置为场景灯");
public HomeAssistantLightTransitionOutput GetStatus()
@@ -44,31 +49,37 @@ public sealed class HomeAssistantLightTransitionService : IHostedService, ILight
// 旧助眠/晨起接口只作用于原卧室灯;额外灯由各自场景模块调用。
public async Task StartSleepAsync(int kelvin, CancellationToken token)
{
+ EnsurePrimaryConfigured();
await _primary.StartSleepAsync(kelvin, token);
return GetStatus();
}
public async Task StartWakeAsync(CancellationToken token)
{
+ EnsurePrimaryConfigured();
await _primary.StartWakeAsync(token);
return GetStatus();
}
public async Task CancelPrimaryAsync(CancellationToken token)
{
+ EnsurePrimaryConfigured();
await _primary.CancelAsync(token);
return GetStatus();
}
+ private void EnsurePrimaryConfigured()
+ {
+ if (string.IsNullOrWhiteSpace(_primary.GetStatus().EntityId)) throw new InvalidOperationException("卧室灯光未配置");
+ }
+
// 一键关闭/场景替换必须取消全部灯的计时任务,防止后续 tick 重新点亮灯。
public async Task CancelAsync(CancellationToken token)
{
foreach (var light in _lights.Values) await light.CancelAsync(token);
- if (_lights.Count == 0) await _primary.CancelAsync(token);
return GetStatus();
}
public Task StartAsync(CancellationToken token) => Task.CompletedTask;
public async Task StopAsync(CancellationToken token)
{
foreach (var light in _lights.Values) await light.StopAsync(token);
- if (_lights.Count == 0) await _primary.StopAsync(token);
}
}
diff --git a/Evergreen.Health.Application/Health/Integrations/HomeAssistant/HomeAssistantOptions.cs b/Evergreen.Health.Application/Health/Integrations/HomeAssistant/HomeAssistantOptions.cs
index ed2e2be..6ccbcd8 100644
--- a/Evergreen.Health.Application/Health/Integrations/HomeAssistant/HomeAssistantOptions.cs
+++ b/Evergreen.Health.Application/Health/Integrations/HomeAssistant/HomeAssistantOptions.cs
@@ -10,11 +10,20 @@ public sealed class HomeAssistantOptions : IConfigurableOptions
public int TimeoutSeconds { get; set; } = 15;
// 匹配已退役设备的明确实体绑定,不按名称或品牌隐藏设备。
public List ExcludedDeviceEntityIds { get; set; } = [];
+ public bool OnlyConfiguredDevices { get; set; }
+ public List DisplayOnlyDevices { get; set; } = [];
public HomeAssistantEntityOptions Entities { get; set; } = new();
public HomeAssistantSleepSceneOptions SleepScene { get; set; } = new();
public List AdditionalSleepLights { get; set; } = [];
}
+public sealed class DisplayOnlyDeviceOptions
+{
+ public string EntityId { get; set; } = string.Empty;
+ public string Name { get; set; } = string.Empty;
+ public string Category { get; set; } = "Other";
+}
+
/// 明确加入舒睡/全关的额外灯具;未配置的灯仍仅提供单独控制。
public sealed class SceneLightOptions
{
diff --git a/Evergreen.Health.Application/Health/Integrations/HomeAssistant/LightTransitionWorker.cs b/Evergreen.Health.Application/Health/Integrations/HomeAssistant/LightTransitionWorker.cs
index 4855568..0fdc6c6 100644
--- a/Evergreen.Health.Application/Health/Integrations/HomeAssistant/LightTransitionWorker.cs
+++ b/Evergreen.Health.Application/Health/Integrations/HomeAssistant/LightTransitionWorker.cs
@@ -71,10 +71,18 @@ internal sealed class LightTransitionWorker : ILightTransitionRuntime
}
public async Task StartSleepAsync(int colorTemperatureKelvin, CancellationToken cancellationToken)
+ => (await StartSleepCoreAsync(colorTemperatureKelvin, onlyIfIdle: false, cancellationToken))!;
+
+ internal Task StartSleepIfIdleAsync(int colorTemperatureKelvin, CancellationToken cancellationToken)
+ => StartSleepCoreAsync(colorTemperatureKelvin, onlyIfIdle: true, cancellationToken);
+
+ private async Task StartSleepCoreAsync(int colorTemperatureKelvin, bool onlyIfIdle,
+ CancellationToken cancellationToken)
{
await _operationLock.WaitAsync(cancellationToken);
try
{
+ if (onlyIfIdle && GetStatus().Running) return null;
await StopCurrentAsync();
var light = await ReadAvailableLightAsync(cancellationToken);
var data = BuildSleepStartData(light, colorTemperatureKelvin);
diff --git a/Evergreen.Health.Application/Health/Integrations/Ib43/Ib43Configuration.cs b/Evergreen.Health.Application/Health/Integrations/Ib43/Ib43Configuration.cs
new file mode 100644
index 0000000..008e75d
--- /dev/null
+++ b/Evergreen.Health.Application/Health/Integrations/Ib43/Ib43Configuration.cs
@@ -0,0 +1,12 @@
+using Microsoft.Extensions.Configuration;
+
+namespace Evergreen.Health.Application.Health.Integrations.Ib43;
+
+public static class Ib43Configuration
+{
+ // 在框架的 JSON 扫描和常规绑定后执行,仅覆盖明确提供的 IB43 环境变量。
+ public static void ApplyEnvironmentOverrides(Ib43Options options)
+ {
+ new ConfigurationBuilder().AddEnvironmentVariables("Ib43__").Build().Bind(options);
+ }
+}
diff --git a/Evergreen.Health.Application/Health/Integrations/Ib43/Ib43DataRepository.cs b/Evergreen.Health.Application/Health/Integrations/Ib43/Ib43DataRepository.cs
new file mode 100644
index 0000000..58b1aae
--- /dev/null
+++ b/Evergreen.Health.Application/Health/Integrations/Ib43/Ib43DataRepository.cs
@@ -0,0 +1,115 @@
+using Evergreen.Health.Application.Health.Entities;
+using Furion.FriendlyException;
+using SqlSugar;
+using System.Security.Cryptography;
+using System.Text;
+using System.Text.Json;
+
+namespace Evergreen.Health.Application.Health.Integrations.Ib43;
+
+public sealed class Ib43DataRepository(ISqlSugarClient db)
+{
+ public static string DeviceKey(string productKey, string serialNumber) =>
+ Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(productKey + "/" + serialNumber)));
+
+ public Task UserEnabled(long userId) => db.Queryable()
+ .AnyAsync(u => u.Id == userId && !u.IsDelete && u.Status == HealthUserStatus.Enabled);
+
+ public async Task AdminEnabled(long userId) => await db.Queryable()
+ .Where(u => u.Id == userId && u.AccountType == AccountTypeEnum.SuperAdmin && u.Status == StatusEnum.Enable).CountAsync() > 0;
+
+ public Task> Bindings(long userId) => db.Queryable()
+ .Where(b => b.UserId == userId).OrderBy(b => b.StartedAtMs, OrderByType.Desc).ToListAsync();
+
+ public Task> ActiveBindings(string productKey, string serialNumber)
+ {
+ var key = DeviceKey(productKey, serialNumber);
+ return db.Queryable().Where(b => b.DeviceKey == key && b.EndedAtMs == null).ToListAsync();
+ }
+
+ public async Task OwnedBinding(long userId, string id) =>
+ await db.Queryable().Where(b => b.Id == id && b.UserId == userId).FirstAsync()
+ ?? throw new AppFriendlyException("床位绑定不存在或无权访问", null) { StatusCode = 404 };
+
+ public async Task Assign(HealthIb43Binding binding)
+ {
+ if (await db.Queryable().AnyAsync(b => b.ActiveSlot == binding.ActiveSlot))
+ throw new AppFriendlyException("该侧床位已分配,请先解绑", null) { StatusCode = 409 };
+ try { await db.Insertable(binding).ExecuteCommandAsync(); }
+ catch
+ {
+ if (await db.Queryable().AnyAsync(b => b.ActiveSlot == binding.ActiveSlot))
+ throw new AppFriendlyException("该侧床位已分配,请先解绑", null) { StatusCode = 409 };
+ throw;
+ }
+ }
+
+ public async Task Release(string id, long adminId, long endedAtMs)
+ {
+ var count = await db.Updateable()
+ .SetColumns(b => new HealthIb43Binding { ActiveSlot = null, EndedAtMs = endedAtMs, ReleasedBy = adminId })
+ .Where(b => b.Id == id && b.EndedAtMs == null).ExecuteCommandAsync();
+ if (count == 0) throw new AppFriendlyException("绑定不存在或已解绑", null) { StatusCode = 404 };
+ }
+
+ public async Task Save(string reportId, Ib43Binding device, Ib43Report report)
+ {
+ if (!report.Accepted || report.Readings.Count == 0) return new(0, []);
+ var matched = 0;
+ var activeBeds = new List();
+ var key = DeviceKey(device.ProductKey, device.SerialNumber);
+ var received = report.ReceivedAt.ToUnixTimeMilliseconds();
+ // 按接收时刻归属;数据库重试期间发生解绑也不转移给新用户。
+ var bindings = await db.Queryable().Where(b => b.DeviceKey == key &&
+ b.Simulation == device.Simulation && b.StartedAtMs <= received &&
+ (b.EndedAtMs == null || b.EndedAtMs > received)).ToListAsync();
+ foreach (var binding in bindings)
+ {
+ var readings = report.Readings.Where(p => p.Key.StartsWith(binding.Side + ".", StringComparison.Ordinal))
+ .ToDictionary(p => p.Key[(binding.Side.Length + 1)..], p => p.Value);
+ if (readings.Count == 0) continue;
+ matched++;
+ if (binding.EndedAtMs == null) activeBeds.Add(new(binding.Id, binding.Side));
+ if (await db.Queryable().AnyAsync(o => o.ReportId == reportId && o.BindingId == binding.Id)) continue;
+ var observation = new HealthIb43Observation
+ {
+ ReportId = reportId, BindingId = binding.Id, UserId = binding.UserId, ReceivedAtMs = received,
+ HasHeartRate = readings.ContainsKey("heartRate"), HasRespiration = readings.ContainsKey("respiration"),
+ HasPresence = readings.ContainsKey("presence"), HasSnoring = readings.ContainsKey("snoring"),
+ ReadingsJson = JsonSerializer.Serialize(readings)
+ };
+ try { await db.Insertable(observation).ExecuteCommandAsync(); }
+ catch
+ {
+ // 同一进程处理的报告重试幂等;没有设备序号,不能对 MQTT 重投宣称去重。
+ if (!await db.Queryable().AnyAsync(o => o.ReportId == reportId && o.BindingId == binding.Id)) throw;
+ }
+ }
+ return new(matched, activeBeds);
+ }
+
+ public async Task> Latest(long userId, string bindingId)
+ {
+ var output = new Dictionary();
+ foreach (var metric in new[] { "heartRate", "respiration", "presence", "snoring" })
+ {
+ var query = db.Queryable().Where(o => o.UserId == userId && o.BindingId == bindingId);
+ query = metric switch
+ {
+ "heartRate" => query.Where(o => o.HasHeartRate),
+ "respiration" => query.Where(o => o.HasRespiration),
+ "presence" => query.Where(o => o.HasPresence),
+ _ => query.Where(o => o.HasSnoring)
+ };
+ var row = await query.OrderBy(o => o.ReceivedAtMs, OrderByType.Desc).OrderBy(o => o.Id, OrderByType.Desc).FirstAsync();
+ if (row != null) output[metric] = JsonSerializer.Deserialize>(row.ReadingsJson)[metric];
+ }
+ return output;
+ }
+
+ public Task> History(long userId, string bindingId, long fromMs, long toMs, long? beforeId, int take)
+ => db.Queryable().Where(o => o.UserId == userId && o.BindingId == bindingId &&
+ o.ReceivedAtMs >= fromMs && o.ReceivedAtMs < toMs)
+ .WhereIF(beforeId.HasValue, o => o.Id < beforeId.Value)
+ .OrderBy(o => o.Id, OrderByType.Desc).Take(take).ToListAsync();
+}
diff --git a/Evergreen.Health.Application/Health/Integrations/Ib43/Ib43LocalBinding.cs b/Evergreen.Health.Application/Health/Integrations/Ib43/Ib43LocalBinding.cs
new file mode 100644
index 0000000..1287f8c
--- /dev/null
+++ b/Evergreen.Health.Application/Health/Integrations/Ib43/Ib43LocalBinding.cs
@@ -0,0 +1,55 @@
+using Evergreen.Health.Application.Health.Entities;
+using SqlSugar;
+
+namespace Evergreen.Health.Application.Health.Integrations.Ib43;
+
+// 仅由本地维护程序调用;不是 Controller,也不注册 HTTP 路由。
+public sealed class Ib43LocalBinding(ISqlSugarClient db, TimeProvider clock)
+{
+ public async Task Run(string phone, Ib43Options options, string actor, bool apply)
+ {
+ var device = options.Devices.SingleOrDefault(d => d.ProductKey == "evergreen-test" && d.SerialNumber == "IB43SIM001");
+ if (device?.Simulation != true) throw new InvalidOperationException("模拟设备白名单不匹配,已停止。");
+ var users = await db.Queryable().Where(u => u.Phone == phone && !u.IsDelete &&
+ u.Status == HealthUserStatus.Enabled).Select(u => new { u.Id }).ToListAsync();
+ if (users.Count != 1) throw new InvalidOperationException("未找到唯一且已启用的健康账号,已停止。");
+ var key = Ib43DataRepository.DeviceKey(device.ProductKey, device.SerialNumber);
+ var slot = key + ".left";
+ var existing = await db.Queryable().Where(b => b.ActiveSlot == slot).FirstAsync();
+ if (existing != null)
+ {
+ if (existing.UserId != users[0].Id || !existing.Simulation || existing.EndedAtMs != null)
+ throw new InvalidOperationException("左侧已被其他用户或非模拟绑定占用,未覆盖或解绑。");
+ return existing;
+ }
+ var binding = new HealthIb43Binding
+ {
+ DeviceKey = key, ProductKey = device.ProductKey, SerialNumber = device.SerialNumber,
+ Side = "left", ActiveSlot = slot, UserId = users[0].Id, BedName = "IB43模拟床垫左侧",
+ Simulation = true, StartedAtMs = clock.GetUtcNow().ToUnixTimeMilliseconds(), AssignedBy = 0
+ };
+ if (!apply) return binding;
+ if (string.IsNullOrWhiteSpace(actor) || actor.Length > 200) throw new InvalidOperationException("维护操作者信息不合法。");
+ var stage = "binding";
+ await db.Ado.BeginTranAsync();
+ try
+ {
+ await db.Insertable(binding).ExecuteCommandAsync();
+ stage = "audit";
+ await db.Insertable(new HealthIb43MaintenanceAudit
+ {
+ Id = Guid.NewGuid().ToString("N"), BindingId = binding.Id, UserId = binding.UserId,
+ Actor = actor, OccurredAtMs = binding.StartedAtMs, Operation = "local-bind-simulator-left"
+ }).ExecuteCommandAsync();
+ stage = "commit";
+ await db.Ado.CommitTranAsync();
+ }
+ catch (Exception error)
+ {
+ await db.Ado.RollbackTranAsync();
+ var code = error is MySqlConnector.MySqlException mysql ? $" MySQL {mysql.Number}" : "";
+ throw new InvalidOperationException($"绑定事务失败 ({stage}, {error.GetType().Name}{code}),请重新预检确认数据库状态。", error);
+ }
+ return binding;
+ }
+}
diff --git a/Evergreen.Health.Application/Health/Integrations/Ib43/Ib43MqttService.cs b/Evergreen.Health.Application/Health/Integrations/Ib43/Ib43MqttService.cs
new file mode 100644
index 0000000..4ff5289
--- /dev/null
+++ b/Evergreen.Health.Application/Health/Integrations/Ib43/Ib43MqttService.cs
@@ -0,0 +1,106 @@
+using Evergreen.Health.Application.Health.Sleep;
+using Microsoft.Extensions.Hosting;
+using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Options;
+
+namespace Evergreen.Health.Application.Health.Integrations.Ib43;
+
+public sealed class Ib43MqttService(IOptions options, IIb43MqttTransportFactory factory,
+ Ib43StateStore store, TimeProvider clock, ILogger logger, IIb43ReportSink sink = null,
+ IBedPresenceSink presenceSink = null) : BackgroundService
+{
+ protected override async Task ExecuteAsync(CancellationToken stoppingToken)
+ {
+ var configuration = options.Value;
+ logger.LogInformation("IB43 MQTT startup: Enabled={Enabled}, Host={Host}, Port={Port}, PasswordConfigured={PasswordConfigured}, Devices={Count}",
+ configuration.Enabled, configuration.Host, configuration.Port,
+ !string.IsNullOrWhiteSpace(configuration.Password), configuration.Devices?.Count ?? 0);
+ if (!configuration.Enabled)
+ {
+ logger.LogInformation("IB43 MQTT disabled; set Ib43__Enabled=true to enable");
+ return;
+ }
+ // 即使直接构造(测试/其他宿主)也不带错误配置进入重试。
+ if (!configuration.IsValid()) throw new InvalidOperationException("Invalid Ib43 configuration");
+ var failures = 0;
+ while (!stoppingToken.IsCancellationRequested)
+ {
+ var connectedAt = clock.GetUtcNow();
+ var connectionId = Guid.NewGuid();
+ using var connection = CancellationTokenSource.CreateLinkedTokenSource(stoppingToken);
+ var connectionToken = connection.Token;
+ try
+ {
+ using var transport = factory.Create();
+ var seenKinds = new HashSet(StringComparer.Ordinal);
+ int? lastMatchedBeds = null;
+ transport.Message += async (topic, payload, retained) =>
+ {
+ var report = store.Receive(topic, payload, retained);
+ if (sink != null && report.Accepted && report.Readings.Count > 0)
+ {
+ var binding = configuration.Devices.Single(d => d.PostTopic == topic);
+ var reportId = Guid.NewGuid().ToString("N");
+ Ib43SavedReport saved;
+ // 等待持久化再结束回调(MQTT 自动 ACK 在此之后)。失败时阻塞重试而非静默丢弃。
+ while (true)
+ {
+ stoppingToken.ThrowIfCancellationRequested();
+ try
+ {
+ saved = await sink.SaveAsync(reportId, binding, report, stoppingToken);
+ break;
+ }
+ catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) { throw; }
+ catch (Exception error)
+ {
+ logger.LogWarning("IB43 persistence failed ({Type}); retrying in 5 seconds", error.GetType().Name);
+ await Task.Delay(TimeSpan.FromSeconds(5), clock, stoppingToken);
+ }
+ }
+ if (saved.MatchedCount != lastMatchedBeds)
+ logger.LogInformation("IB43 persistence: matched beds={Count}; zero means no user observation saved", saved.MatchedCount);
+ lastMatchedBeds = saved.MatchedCount;
+ // 发布失败与写库重试分开,联动问题不能导致重写已保存的报文。
+ try { Ib43PresenceAdapter.Publish(presenceSink, binding, report, saved.ActiveBeds, connectionId, connectionToken); }
+ catch (Exception error)
+ {
+ logger.LogWarning("IB43 presence publication failed ({Type})", error.GetType().Name);
+ }
+ }
+ if (!report.Accepted || report.Issues.Count > 0)
+ logger.LogDebug("IB43 report has {Count} parsing issues", report.Issues.Count);
+ if (report.Accepted && (report.Readings.Count > 0 || report.Fields.Count > 0))
+ foreach (var kind in report.Kinds)
+ if (seenKinds.Add(kind))
+ logger.LogInformation("IB43 first {Kind} report received in this connection", kind);
+ };
+ transport.OversizedMessage += store.RejectOversized;
+ store.SetTransport("connecting");
+ using var timeout = CancellationTokenSource.CreateLinkedTokenSource(stoppingToken);
+ timeout.CancelAfter(TimeSpan.FromSeconds(15));
+ await transport.ConnectAndSubscribeAsync(configuration, timeout.Token);
+ store.SetTransport("connected");
+ connectedAt = clock.GetUtcNow();
+ logger.LogInformation("IB43 MQTT connected and subscribed to {Count} configured devices", configuration.Devices.Count);
+ while (transport.IsConnected)
+ await Task.Delay(TimeSpan.FromSeconds(1), clock, stoppingToken);
+ }
+ catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) { break; }
+ catch (Exception exception)
+ {
+ // 不写密码、连接选项、完整异常文本或原始健康报文。
+ logger.LogWarning("IB43 MQTT connection/subscription failed ({Type}); retrying", exception.GetType().Name);
+ }
+ finally
+ {
+ connection.Cancel();
+ store.SetTransport("disconnected");
+ }
+ if (clock.GetUtcNow() - connectedAt > TimeSpan.FromMinutes(1)) failures = 0;
+ var seconds = Math.Min(30, Math.Pow(2, Math.Min(failures++, 5)));
+ try { await Task.Delay(TimeSpan.FromSeconds(seconds), clock, stoppingToken); }
+ catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) { break; }
+ }
+ }
+}
diff --git a/Evergreen.Health.Application/Health/Integrations/Ib43/Ib43MqttTransport.cs b/Evergreen.Health.Application/Health/Integrations/Ib43/Ib43MqttTransport.cs
new file mode 100644
index 0000000..d61174d
--- /dev/null
+++ b/Evergreen.Health.Application/Health/Integrations/Ib43/Ib43MqttTransport.cs
@@ -0,0 +1,63 @@
+using MQTTnet;
+using MQTTnet.Client;
+using MQTTnet.Formatter;
+using MQTTnet.Protocol;
+
+namespace Evergreen.Health.Application.Health.Integrations.Ib43;
+
+public interface IIb43MqttTransport : IDisposable
+{
+ bool IsConnected { get; }
+ event Func Message;
+ event Action OversizedMessage;
+ Task ConnectAndSubscribeAsync(Ib43Options options, CancellationToken cancellationToken);
+}
+
+public interface IIb43MqttTransportFactory
+{
+ IIb43MqttTransport Create();
+}
+
+public sealed class Ib43MqttTransportFactory : IIb43MqttTransportFactory
+{
+ public IIb43MqttTransport Create() => new Ib43MqttTransport();
+}
+
+public sealed class Ib43MqttTransport : IIb43MqttTransport
+{
+ private readonly IMqttClient _client = new MqttFactory().CreateMqttClient();
+ public bool IsConnected => _client.IsConnected;
+ public event Func Message;
+ public event Action OversizedMessage;
+
+ public Ib43MqttTransport()
+ {
+ _client.ApplicationMessageReceivedAsync += async e =>
+ {
+ var m = e.ApplicationMessage;
+ // 在解码前限制长度;不要将大包全部复制进诊断缓存。
+ if (m.PayloadSegment.Count > Ib43StateStore.MaxPayloadBytes)
+ OversizedMessage?.Invoke(m.Topic);
+ else if (Message != null) await Message.Invoke(m.Topic, m.ConvertPayloadToString(), m.Retain);
+ };
+ }
+
+ public async Task ConnectAndSubscribeAsync(Ib43Options options, CancellationToken cancellationToken)
+ {
+ var builder = new MqttClientOptionsBuilder().WithTcpServer(options.Host, options.Port)
+ .WithClientId(options.ClientId).WithCredentials(options.Username, options.Password)
+ .WithProtocolVersion(MqttProtocolVersion.V311).WithCleanSession()
+ .WithKeepAlivePeriod(TimeSpan.FromSeconds(30)).WithTimeout(TimeSpan.FromSeconds(10));
+ if (options.UseTls) builder.WithTlsOptions(t => t.UseTls());
+ await _client.ConnectAsync(builder.Build(), cancellationToken);
+ var subscription = new MqttClientSubscribeOptionsBuilder();
+ foreach (var device in options.Devices)
+ subscription.WithTopicFilter(f => f.WithTopic(device.PostTopic)
+ .WithQualityOfServiceLevel(MqttQualityOfServiceLevel.AtLeastOnce));
+ var result = await _client.SubscribeAsync(subscription.Build(), cancellationToken);
+ if (result.Items.Count != options.Devices.Count || result.Items.Any(i => (int)i.ResultCode >= 128))
+ throw new InvalidOperationException("IB43 subscription rejected");
+ }
+
+ public void Dispose() => _client.Dispose();
+}
diff --git a/Evergreen.Health.Application/Health/Integrations/Ib43/Ib43Options.cs b/Evergreen.Health.Application/Health/Integrations/Ib43/Ib43Options.cs
new file mode 100644
index 0000000..417baf5
--- /dev/null
+++ b/Evergreen.Health.Application/Health/Integrations/Ib43/Ib43Options.cs
@@ -0,0 +1,36 @@
+namespace Evergreen.Health.Application.Health.Integrations.Ib43;
+
+public sealed class Ib43Options
+{
+ public bool Enabled { get; set; }
+ public string Host { get; set; } = "192.168.2.2";
+ public int Port { get; set; } = 1883;
+ public bool UseTls { get; set; }
+ public string Username { get; set; } = "evergreen-backend";
+ public string Password { get; set; } = "";
+ public string ClientId { get; set; } = "evergreen-health-backend";
+ public int StaleAfterSeconds { get; set; } = 150;
+ public List Devices { get; set; } = [];
+
+ public bool IsValid() => !Enabled ||
+ (!string.IsNullOrWhiteSpace(Host) && Port is > 0 and <= 65535 &&
+ !string.IsNullOrWhiteSpace(Username) && !string.IsNullOrWhiteSpace(Password) &&
+ !string.IsNullOrWhiteSpace(ClientId) && StaleAfterSeconds > 0 &&
+ Devices is { Count: > 0 } && Devices.All(d => d != null &&
+ ValidSegment(d.ProductKey) && ValidSegment(d.SerialNumber) &&
+ d.PersonLeftIndex is null or 0 or 1) &&
+ Devices.Select(d => d.PostTopic).Distinct(StringComparer.Ordinal).Count() == Devices.Count);
+
+ private static bool ValidSegment(string value) => !string.IsNullOrWhiteSpace(value) &&
+ !value.Any(c => c is '/' or '+' or '#' || char.IsControl(c));
+}
+
+public sealed class Ib43Binding
+{
+ public string ProductKey { get; set; } = "";
+ public string SerialNumber { get; set; } = "";
+ public bool Simulation { get; set; } = true;
+ // 实机验证前保持 null;不可从数组顺序猜左右。
+ public int? PersonLeftIndex { get; set; }
+ public string PostTopic => $"/sys/{ProductKey}/{SerialNumber}/thing/property/post";
+}
diff --git a/Evergreen.Health.Application/Health/Integrations/Ib43/Ib43Parser.cs b/Evergreen.Health.Application/Health/Integrations/Ib43/Ib43Parser.cs
new file mode 100644
index 0000000..ee85ca6
--- /dev/null
+++ b/Evergreen.Health.Application/Health/Integrations/Ib43/Ib43Parser.cs
@@ -0,0 +1,175 @@
+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");
+ }
+}
diff --git a/Evergreen.Health.Application/Health/Integrations/Ib43/Ib43PresenceAdapter.cs b/Evergreen.Health.Application/Health/Integrations/Ib43/Ib43PresenceAdapter.cs
new file mode 100644
index 0000000..f22b81d
--- /dev/null
+++ b/Evergreen.Health.Application/Health/Integrations/Ib43/Ib43PresenceAdapter.cs
@@ -0,0 +1,24 @@
+using Evergreen.Health.Application.Health.Sleep;
+
+namespace Evergreen.Health.Application.Health.Integrations.Ib43;
+
+public sealed record Ib43SavedBed(string BindingId, string Side);
+public sealed record Ib43SavedReport(int MatchedCount, IReadOnlyList ActiveBeds);
+
+internal static class Ib43PresenceAdapter
+{
+ public static void Publish(IBedPresenceSink sink, Ib43Binding device, Ib43Report report,
+ IReadOnlyList beds, Guid connectionId, CancellationToken connectionToken)
+ {
+ if (sink == null || !report.Accepted || report.Issues.Count != 0) return;
+ foreach (var bed in beds)
+ {
+ if (bed.Side is not ("left" or "right") ||
+ !report.Readings.TryGetValue(bed.Side + ".presence", out var presence) ||
+ presence.Source != "ibNew" || presence.Value is not (0 or 1)) continue;
+ sink.Observe(new(bed.BindingId, device.ProductKey, device.SerialNumber,
+ bed.Side == "left" ? BedSide.Left : BedSide.Right, device.Simulation,
+ presence.Value == 0 ? BedPresence.Empty : BedPresence.Occupied, presence.ReceivedAt, connectionId), connectionToken);
+ }
+ }
+}
diff --git a/Evergreen.Health.Application/Health/Integrations/Ib43/Ib43ReportSink.cs b/Evergreen.Health.Application/Health/Integrations/Ib43/Ib43ReportSink.cs
new file mode 100644
index 0000000..7240ced
--- /dev/null
+++ b/Evergreen.Health.Application/Health/Integrations/Ib43/Ib43ReportSink.cs
@@ -0,0 +1,16 @@
+namespace Evergreen.Health.Application.Health.Integrations.Ib43;
+
+public interface IIb43ReportSink
+{
+ Task SaveAsync(string reportId, Ib43Binding binding, Ib43Report report, CancellationToken token);
+}
+
+public sealed class Ib43ReportSink(IServiceScopeFactory scopes) : IIb43ReportSink
+{
+ public async Task SaveAsync(string reportId, Ib43Binding binding, Ib43Report report, CancellationToken token)
+ {
+ token.ThrowIfCancellationRequested();
+ using var scope = scopes.CreateScope();
+ return await scope.ServiceProvider.GetRequiredService().Save(reportId, binding, report);
+ }
+}
diff --git a/Evergreen.Health.Application/Health/Integrations/Ib43/Ib43StateStore.cs b/Evergreen.Health.Application/Health/Integrations/Ib43/Ib43StateStore.cs
new file mode 100644
index 0000000..07c4bec
--- /dev/null
+++ b/Evergreen.Health.Application/Health/Integrations/Ib43/Ib43StateStore.cs
@@ -0,0 +1,88 @@
+using Microsoft.Extensions.Options;
+using System.Text.Json;
+
+namespace Evergreen.Health.Application.Health.Integrations.Ib43;
+
+public sealed record Ib43Field(JsonElement Value, DateTimeOffset ReceivedAt);
+public sealed record Ib43Snapshot(string ProductKey, string SerialNumber, bool Simulation,
+ string Transport, string Freshness, DateTimeOffset? LastTelemetryAt,
+ IReadOnlyDictionary Readings, IReadOnlyDictionary Fields);
+public sealed record Ib43Diagnostic(string Topic, DateTimeOffset ReceivedAt, string Raw, string[] Issues);
+
+/// 进程内有界缓存。没有设备遗嘱,不能将数据超时等同于已确认设备离线。
+public sealed class Ib43StateStore(IOptions options, Ib43Parser parser, TimeProvider clock)
+{
+ public const int MaxPayloadBytes = 65536;
+ private readonly object _gate = new();
+ private readonly Dictionary _states = new(StringComparer.Ordinal);
+ private readonly Queue _diagnostics = new();
+ private string _transport = options.Value.Enabled ? "disconnected" : "disabled";
+
+ private sealed class DeviceState
+ {
+ public DateTimeOffset? LastTelemetryAt;
+ public Dictionary Readings { get; } = new(StringComparer.Ordinal);
+ public Dictionary Fields { get; } = new(StringComparer.Ordinal);
+ }
+
+ public void SetTransport(string state) { lock (_gate) _transport = state; }
+
+ public void RejectOversized(string topic)
+ {
+ lock (_gate)
+ {
+ _diagnostics.Enqueue(new(topic, clock.GetUtcNow(), "", ["payload-too-large:body-not-retained"]));
+ while (_diagnostics.Count > 32) _diagnostics.Dequeue();
+ }
+ }
+
+ public Ib43Report Receive(string topic, string payload, bool retained = false)
+ {
+ lock (_gate)
+ {
+ var now = clock.GetUtcNow();
+ var binding = options.Value.Devices.FirstOrDefault(d => d.PostTopic == topic);
+ Ib43Report report;
+ if (binding == null || retained || System.Text.Encoding.UTF8.GetByteCount(payload) > MaxPayloadBytes)
+ {
+ report = new() { Raw = payload.Length > 4096 ? payload[..4096] : payload, ReceivedAt = now };
+ report.Issues.Add(binding == null ? "unbound-topic" : retained ? "retained-not-live" : "payload-too-large");
+ }
+ else report = parser.Parse(topic, payload, binding, now);
+
+ _diagnostics.Enqueue(new(topic, now, report.Raw, report.Issues.ToArray()));
+ while (_diagnostics.Count > 32) _diagnostics.Dequeue();
+ if (!report.Accepted) return report;
+ if (!_states.TryGetValue(topic, out var state)) _states[topic] = state = new();
+ foreach (var (key, value) in report.Readings) state.Readings[key] = value;
+ foreach (var (key, value) in report.Fields) state.Fields[key] = new(value, now);
+ // 只有有效实时字段刷新活跃性;能力包、未知包和坏包不能掩盖实时数据停止。
+ if (report.Readings.Count > 0 || report.Fields.Keys.Any(k => k is "airbagsPerson" or
+ "airbagsPressure" or "motorHeight" or "motorStatus" or "snoreStatus"))
+ state.LastTelemetryAt = now;
+ return report;
+ }
+ }
+
+ public Ib43Snapshot[] Snapshot()
+ {
+ lock (_gate)
+ {
+ var now = clock.GetUtcNow();
+ return options.Value.Devices.Select(binding =>
+ {
+ _states.TryGetValue(binding.PostTopic, out var state);
+ var last = state?.LastTelemetryAt;
+ var freshness = last == null ? "unknown" :
+ now - last.Value > TimeSpan.FromSeconds(options.Value.StaleAfterSeconds) ? "stale" : "recent";
+ return new Ib43Snapshot(binding.ProductKey, binding.SerialNumber, binding.Simulation,
+ _transport, freshness, last,
+ state == null ? new Dictionary() : new(state.Readings),
+ state == null ? new Dictionary() : new(state.Fields));
+ }).ToArray();
+ }
+ }
+
+ // 不通过公共 H5 API 暴露原始健康数据;后续持久化需绑定用户权限与保留期限。
+ public Ib43Diagnostic[] Diagnostics() { lock (_gate) return _diagnostics.ToArray(); }
+}
diff --git a/Evergreen.Health.Application/Health/Scenes/DeviceActionRunner.cs b/Evergreen.Health.Application/Health/Scenes/DeviceActionRunner.cs
index 593089a..0ad0e73 100644
--- a/Evergreen.Health.Application/Health/Scenes/DeviceActionRunner.cs
+++ b/Evergreen.Health.Application/Health/Scenes/DeviceActionRunner.cs
@@ -86,22 +86,23 @@ public sealed class DeviceActionRunner
}
}
- public async Task VerifyParameterAsync(DeviceCommand command, CancellationToken cancellationToken)
+ public async Task VerifyParameterAsync(DeviceCommand command, CancellationToken cancellationToken, int? verificationAttempts = null)
{
var results = new List
{
new() { Name = command.Name, EntityId = command.EntityId, Status = "Succeeded" }
};
- await VerifyBatchAsync([command], results, cancellationToken);
+ await VerifyBatchAsync([command], results, cancellationToken, verificationAttempts);
if (results[0].Status != "Succeeded") throw new InvalidOperationException(results[0].Message);
}
public async Task VerifyBatchAsync(IReadOnlyList commands,
- List results, CancellationToken cancellationToken)
+ List results, CancellationToken cancellationToken, int? verificationAttempts = null)
{
+ var attempts = verificationAttempts ?? _options.VerificationAttempts;
var pending = commands.Where(command => !string.IsNullOrWhiteSpace(command.EntityId) &&
command.ExpectedState is not null).ToList();
- for (var round = 0; round < _options.VerificationAttempts && pending.Count > 0; round++)
+ for (var round = 0; round < attempts && pending.Count > 0; round++)
{
await DelayAsync(_options.VerificationDelay, cancellationToken);
IReadOnlyList states;
@@ -126,7 +127,7 @@ public sealed class DeviceActionRunner
action.Status = "Succeeded"; action.Message = round == 0 ? "已回读确认目标状态" : "重试后已回读确认目标状态";
pending.Remove(command); continue;
}
- if (round == _options.VerificationAttempts - 1)
+ if (round == attempts - 1)
{
action.Status = "Failed"; action.Message = $"未确认目标{(command.ExpectedAttribute is null ? "状态" : command.ExpectedAttribute)} {command.ExpectedState}(当前:{actual ?? "未读取到"})";
pending.Remove(command); continue;
diff --git a/Evergreen.Health.Application/Health/Services/HealthIb43Service.cs b/Evergreen.Health.Application/Health/Services/HealthIb43Service.cs
new file mode 100644
index 0000000..a8ade2f
--- /dev/null
+++ b/Evergreen.Health.Application/Health/Services/HealthIb43Service.cs
@@ -0,0 +1,139 @@
+using Evergreen.Health.Application.Health.Entities;
+using Evergreen.Health.Application.Health.Integrations.Ib43;
+using Furion.FriendlyException;
+using Microsoft.AspNetCore.Http;
+using Microsoft.Extensions.Options;
+using System.ComponentModel.DataAnnotations;
+using System.Security.Claims;
+using System.Text.Json;
+
+namespace Evergreen.Health.Application.Health.Services;
+
+public sealed class AssignIb43BedInput
+{
+ [Required, MaxLength(128)] public string ProductKey { get; set; }
+ [Required, MaxLength(128)] public string SerialNumber { get; set; }
+ [Required, RegularExpression("^(left|right)$")] public string Side { get; set; }
+ [Range(1, long.MaxValue)] public long UserId { get; set; }
+ [Required, MaxLength(80)] public string BedName { get; set; }
+}
+
+public sealed record Ib43BedOutput(string Id, string ProductKey, string SerialNumber, string Side,
+ string BedName, bool Simulation, long StartedAtMs, long? EndedAtMs);
+public sealed record Ib43AdminBedOutput(Ib43BedOutput Bed, long UserId);
+public sealed record Ib43MetricOutput(double? Value, DateTimeOffset? ReceivedAt, string Source, string Freshness);
+public sealed record Ib43CurrentOutput(Ib43BedOutput Bed, string Transport, bool Active,
+ IReadOnlyDictionary Readings);
+public sealed record Ib43HistoryItem(long Id, long ReceivedAtMs, IReadOnlyDictionary Readings);
+public sealed record Ib43HistoryOutput(IReadOnlyList Items, long? NextBeforeId);
+
+[Authorize]
+[ApiDescriptionSettings("健康管理", Name = "HealthIb43", Order = 53)]
+public sealed class HealthIb43Service(Ib43DataRepository repository, IHttpContextAccessor context,
+ IOptions options, Ib43StateStore states, TimeProvider clock) : IDynamicApiController, ITransient
+{
+ [HttpGet("/api/health/ib43/admin/bindings")]
+ public async Task ActiveBindings(string productKey, string serialNumber)
+ {
+ await RequireAdmin();
+ if (string.IsNullOrWhiteSpace(productKey) || string.IsNullOrWhiteSpace(serialNumber) || productKey.Length > 128 || serialNumber.Length > 128)
+ throw new AppFriendlyException("请指定产品和设备编号", null) { StatusCode = 400 };
+ return (await repository.ActiveBindings(productKey, serialNumber)).Select(b => new Ib43AdminBedOutput(Map(b), b.UserId)).ToArray();
+ }
+
+ [HttpPost("/api/health/ib43/admin/bindings")]
+ public async Task Assign(AssignIb43BedInput input)
+ {
+ var admin = await RequireAdmin();
+ if (input == null || input.Side is not ("left" or "right") || string.IsNullOrWhiteSpace(input.BedName) ||
+ input.BedName.Length > 80 || input.UserId <= 0)
+ throw new AppFriendlyException("床位参数不合法", null) { StatusCode = 400 };
+ var device = options.Value.Devices.SingleOrDefault(d => d.ProductKey == input.ProductKey && d.SerialNumber == input.SerialNumber);
+ if (device == null || input.ProductKey.Length > 128 || input.SerialNumber.Length > 128)
+ throw new AppFriendlyException("设备不在后台接入白名单中", null) { StatusCode = 400 };
+ if (!await repository.UserEnabled(input.UserId)) throw new AppFriendlyException("目标健康用户不存在或已停用", null) { StatusCode = 400 };
+ var key = Ib43DataRepository.DeviceKey(device.ProductKey, device.SerialNumber);
+ var binding = new HealthIb43Binding
+ {
+ DeviceKey = key, ProductKey = device.ProductKey, SerialNumber = device.SerialNumber,
+ Side = input.Side, ActiveSlot = key + "." + input.Side, UserId = input.UserId,
+ BedName = input.BedName.Trim(), Simulation = device.Simulation,
+ StartedAtMs = clock.GetUtcNow().ToUnixTimeMilliseconds(), AssignedBy = admin
+ };
+ await repository.Assign(binding);
+ return Map(binding);
+ }
+
+ [HttpDelete("/api/health/ib43/admin/bindings/{bindingId}")]
+ public async Task Release(string bindingId)
+ {
+ var admin = await RequireAdmin();
+ await repository.Release(bindingId, admin, clock.GetUtcNow().ToUnixTimeMilliseconds());
+ }
+
+ [HttpGet("/api/health/ib43/bindings")]
+ public async Task Bindings()
+ {
+ var user = await RequireUser();
+ return (await repository.Bindings(user)).Select(Map).ToArray();
+ }
+
+ [HttpGet("/api/health/ib43/bindings/{bindingId}/current")]
+ public async Task Current(string bindingId)
+ {
+ var user = await RequireUser();
+ var binding = await repository.OwnedBinding(user, bindingId);
+ var readings = await repository.Latest(user, bindingId);
+ var now = clock.GetUtcNow();
+ var projected = new Dictionary();
+ foreach (var key in new[] { "heartRate", "respiration", "presence", "snoring" })
+ {
+ readings.TryGetValue(key, out var reading);
+ projected[key] = reading == null ? new(null, null, null, "unknown") :
+ new(reading.Value, reading.ReceivedAt, reading.Source,
+ now - reading.ReceivedAt > TimeSpan.FromSeconds(options.Value.StaleAfterSeconds) ? "stale" : "recent");
+ }
+ var transport = states.Snapshot().FirstOrDefault(s => s.ProductKey == binding.ProductKey &&
+ s.SerialNumber == binding.SerialNumber && s.Simulation == binding.Simulation)?.Transport ?? "unconfigured";
+ return new(Map(binding), transport, binding.EndedAtMs == null, projected);
+ }
+
+ [HttpGet("/api/health/ib43/bindings/{bindingId}/history")]
+ public async Task History(string bindingId, long fromMs, long toMs, long? beforeId = null, int take = 100)
+ {
+ var user = await RequireUser();
+ await repository.OwnedBinding(user, bindingId);
+ // 固定半开 UTC 毫秒区间;游标按数据库接收记录Id倒序,避免同毫秒漏页。
+ if (fromMs < 0 || toMs <= fromMs || toMs - fromMs > 7L * 86400000 || take is < 1 or > 200 || beforeId is <= 0)
+ throw new AppFriendlyException("查询范围最多7天,每页1至200条", null) { StatusCode = 400 };
+ var rows = await repository.History(user, bindingId, fromMs, toMs, beforeId, take + 1);
+ var more = rows.Count > take;
+ var items = rows.Take(take).Select(r => new Ib43HistoryItem(r.Id, r.ReceivedAtMs,
+ JsonSerializer.Deserialize>(r.ReadingsJson))).ToArray();
+ return new(items, more ? items[^1].Id : null);
+ }
+
+ private async Task RequireUser()
+ {
+ var principal = context.HttpContext?.User;
+ if (principal?.Identity?.IsAuthenticated != true ||
+ !long.TryParse(principal.FindFirstValue("health_user_id"), out var user) || user <= 0)
+ throw new AppFriendlyException("请先登录健康端账号", null) { StatusCode = 401 };
+ if (!await repository.UserEnabled(user)) throw new AppFriendlyException("健康用户已停用或不存在", null) { StatusCode = 403 };
+ return user;
+ }
+
+ private async Task RequireAdmin()
+ {
+ var principal = context.HttpContext?.User;
+ if (principal?.Identity?.IsAuthenticated != true || principal.HasClaim(c => c.Type == "health_user_id") ||
+ principal.FindFirstValue(ClaimConst.AccountType) != ((int)AccountTypeEnum.SuperAdmin).ToString() ||
+ !long.TryParse(principal.FindFirstValue(ClaimConst.UserId), out var admin) || admin <= 0)
+ throw new AppFriendlyException("只有后台超级管理员可以分配床位", null) { StatusCode = 403 };
+ if (!await repository.AdminEnabled(admin)) throw new AppFriendlyException("后台管理员已失效", null) { StatusCode = 403 };
+ return admin;
+ }
+
+ private static Ib43BedOutput Map(HealthIb43Binding b) => new(b.Id, b.ProductKey, b.SerialNumber,
+ b.Side, b.BedName, b.Simulation, b.StartedAtMs, b.EndedAtMs);
+}
diff --git a/Evergreen.Health.Application/Health/Sleep/BedPresenceAutomation.cs b/Evergreen.Health.Application/Health/Sleep/BedPresenceAutomation.cs
new file mode 100644
index 0000000..c1188ac
--- /dev/null
+++ b/Evergreen.Health.Application/Health/Sleep/BedPresenceAutomation.cs
@@ -0,0 +1,144 @@
+using Microsoft.Extensions.Hosting;
+using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Options;
+using System.Threading.Channels;
+
+namespace Evergreen.Health.Application.Health.Sleep;
+
+public enum BedSide { Left, Right }
+public enum BedAutomationMode { Disabled, Preview, Execute }
+
+public sealed record BedPresenceSample(string BindingId, string ProductKey, string SerialNumber, BedSide Side,
+ bool Simulation, BedPresence Presence, DateTimeOffset ReceivedAt, Guid ConnectionId);
+
+public interface IBedPresenceSink
+{
+ void Observe(BedPresenceSample sample, CancellationToken connectionToken);
+}
+
+public interface IBedPresenceAction
+{
+ string Key { get; }
+ string Description { get; }
+ TimeSpan ExecutionTimeout => TimeSpan.FromSeconds(25);
+ bool SupportsTarget(string entityId);
+ Task ExecuteAsync(string entityId, CancellationToken token);
+}
+
+public sealed class BedPresenceAutomationOptions
+{
+ public BedAutomationMode Mode { get; set; }
+ public string BindingId { get; set; } = "";
+ public string ProductKey { get; set; } = "evergreen-test";
+ public string SerialNumber { get; set; } = "IB43SIM001";
+ public BedSide Side { get; set; } = BedSide.Left;
+ public string ActionKey { get; set; } = "";
+ public string TargetEntityId { get; set; } = "";
+
+ public bool IsValid() => Mode == BedAutomationMode.Disabled ||
+ (Mode is BedAutomationMode.Preview or BedAutomationMode.Execute && Guid.TryParseExact(BindingId, "N", out _) &&
+ !string.IsNullOrWhiteSpace(ProductKey) && !string.IsNullOrWhiteSpace(SerialNumber) &&
+ Enum.IsDefined(Side) && !string.IsNullOrWhiteSpace(ActionKey) && !string.IsNullOrWhiteSpace(TargetEntityId));
+
+ public bool Matches(BedPresenceSample sample) => sample.Simulation && sample.BindingId == BindingId &&
+ sample.ProductKey == ProductKey && sample.SerialNumber == SerialNumber && sample.Side == Side;
+}
+
+/// 按连接识别连续的离床到在床,首条、重复和超时事件只建立基线。
+public sealed class BedPresenceEdge
+{
+ public static readonly TimeSpan MaxAge = TimeSpan.FromSeconds(60);
+ private BedPresenceSample? _previous;
+
+ public bool Observe(BedPresenceSample sample, DateTimeOffset now)
+ {
+ if (sample.Presence is not (BedPresence.Empty or BedPresence.Occupied) ||
+ sample.ReceivedAt > now || now - sample.ReceivedAt > MaxAge) return false;
+ var previous = _previous;
+ var sameBedConnection = previous != null && previous.ConnectionId == sample.ConnectionId &&
+ previous.BindingId == sample.BindingId && previous.Side == sample.Side;
+ if (sameBedConnection && sample.ReceivedAt < previous!.ReceivedAt) return false;
+ _previous = sample;
+ return sameBedConnection && previous!.Presence == BedPresence.Empty &&
+ sample.Presence == BedPresence.Occupied && sample.ReceivedAt - previous.ReceivedAt <= MaxAge;
+ }
+
+ public void Reset() => _previous = null;
+}
+
+/// 异步执行单条联调规则。采集线程只提交归一化事件。
+public sealed class BedPresenceAutomation(IOptions options, IEnumerable actions,
+ TimeProvider clock, ILogger logger) : BackgroundService, IBedPresenceSink
+{
+ private sealed record Item(long Sequence, BedPresenceSample Sample, CancellationToken ConnectionToken);
+ private readonly Channel- _queue = Channel.CreateBounded
- (new BoundedChannelOptions(32)
+ { SingleReader = true, FullMode = BoundedChannelFullMode.Wait });
+ private long _sequence;
+ private volatile bool _accepting;
+
+ public void Observe(BedPresenceSample sample, CancellationToken connectionToken)
+ {
+ var configuration = options.Value;
+ if (!_accepting || !configuration.Matches(sample) || connectionToken.IsCancellationRequested) return;
+ if (!_queue.Writer.TryWrite(new(Interlocked.Increment(ref _sequence), sample, connectionToken)))
+ logger.LogWarning("Bed presence automation queue full; observation dropped");
+ }
+
+ protected override async Task ExecuteAsync(CancellationToken stoppingToken)
+ {
+ var configuration = options.Value;
+ if (configuration.Mode == BedAutomationMode.Disabled) return;
+ var candidates = actions.Where(action => string.Equals(action.Key, configuration.ActionKey, StringComparison.Ordinal)).Take(2).ToArray();
+ if (!configuration.IsValid() || candidates.Length != 1 || !candidates[0].SupportsTarget(configuration.TargetEntityId))
+ {
+ logger.LogWarning("Bed presence automation inactive: invalid binding, action or target");
+ return;
+ }
+ var action = candidates[0];
+ var target = configuration.TargetEntityId;
+ _accepting = true;
+ logger.LogInformation("Bed presence automation ready: Mode={Mode}, Side={Side}, Action={Action}, Target={EntityId}",
+ configuration.Mode, configuration.Side, action.Key, target);
+ var edge = new BedPresenceEdge();
+ long lastSequence = 0;
+ await foreach (var item in _queue.Reader.ReadAllAsync(stoppingToken))
+ {
+ if (item.Sequence != lastSequence + 1 || item.ConnectionToken.IsCancellationRequested) edge.Reset();
+ lastSequence = item.Sequence;
+ if (item.ConnectionToken.IsCancellationRequested || !edge.Observe(item.Sample, clock.GetUtcNow())) continue;
+ if (configuration.Mode == BedAutomationMode.Preview)
+ {
+ logger.LogInformation("Bed presence preview: empty -> occupied, Action={Action}, Plan={Plan}, Target={EntityId}",
+ action.Key, action.Description, target);
+ continue;
+ }
+ if (clock.GetUtcNow() - item.Sample.ReceivedAt > TimeSpan.FromSeconds(10))
+ {
+ logger.LogInformation("Bed presence action skipped: observation aged in queue");
+ continue;
+ }
+ using var timeout = new CancellationTokenSource(action.ExecutionTimeout, clock);
+ using var operation = CancellationTokenSource.CreateLinkedTokenSource(stoppingToken, item.ConnectionToken, timeout.Token);
+ try
+ {
+ operation.Token.ThrowIfCancellationRequested();
+ var result = await action.ExecuteAsync(target, operation.Token);
+ logger.LogInformation("Bed presence action: Action={Action}, Target={EntityId}, Result={Result}", action.Key, target, result);
+ }
+ catch (OperationCanceledException)
+ {
+ logger.LogWarning("Bed presence action not confirmed: cancelled or timed out for {EntityId}", target);
+ }
+ catch (Exception error)
+ {
+ logger.LogWarning("Bed presence action not confirmed: Target={EntityId}, Error={Type}", target, error.GetType().Name);
+ }
+ }
+ }
+
+ public override Task StopAsync(CancellationToken cancellationToken)
+ {
+ _accepting = false;
+ return base.StopAsync(cancellationToken);
+ }
+}
diff --git a/Tools/Ib43.LocalMaintenance/Ib43.LocalMaintenance.csproj b/Tools/Ib43.LocalMaintenance/Ib43.LocalMaintenance.csproj
new file mode 100644
index 0000000..45f813c
--- /dev/null
+++ b/Tools/Ib43.LocalMaintenance/Ib43.LocalMaintenance.csproj
@@ -0,0 +1,10 @@
+
+
+ Exe
+ net8.0
+ enable
+
+
+
+
+
diff --git a/Tools/Ib43.LocalMaintenance/Program.cs b/Tools/Ib43.LocalMaintenance/Program.cs
new file mode 100644
index 0000000..1fe8a90
--- /dev/null
+++ b/Tools/Ib43.LocalMaintenance/Program.cs
@@ -0,0 +1,71 @@
+using Admin.NET.Core;
+using Evergreen.Health.Application.Health.Entities;
+using Evergreen.Health.Application.Health.Integrations.Ib43;
+using Microsoft.Extensions.Configuration;
+using SqlSugar;
+using System.Text.RegularExpressions;
+
+if (args.Length is < 2 or > 3 || !Regex.IsMatch(args[1], "^1[3-9][0-9]{9}$") ||
+ (args.Length == 3 && args[2] is not ("--apply" or "--verify")))
+{
+ Console.Error.WriteLine("用法: <项目根目录> <健康端手机号> [--apply|--verify];默认只预检。");
+ return 1;
+}
+try
+{
+ var directory = Path.Combine(Path.GetFullPath(args[0]), "Admin.NET", "Evergreen.Health.Application", "Configuration");
+ var config = new ConfigurationBuilder().AddJsonFile(Path.Combine(directory, "Database.json"))
+ .AddJsonFile(Path.Combine(directory, "Ib43.json")).AddEnvironmentVariables().Build();
+ var dbOptions = config.GetSection("DbConnection").Get();
+ dbOptions.PostConfigure(dbOptions, config);
+ var connection = dbOptions.ConnectionConfigs.Single(c => c.ConfigId?.ToString() == SqlSugarConst.MainConfigId);
+ if (connection.DbSettings.EnableConnStringEncrypt)
+ throw new InvalidOperationException("该工具不支持加密连接配置,已停止。");
+ if (connection.DbType is not (DbType.MySql or DbType.MySqlConnector))
+ throw new InvalidOperationException("当前维护工具只允许项目 MySQL 主库。");
+ var options = new Ib43Options();
+ config.GetSection("Ib43").Bind(options);
+ Ib43Configuration.ApplyEnvironmentOverrides(options);
+ SqlSugarSetup.SetDbConfig(connection);
+ // 控制台没有 Web 容器。沿用表和列映射,但不调用依赖 Web DI 的二级缓存。
+ connection.ConfigureExternalServices.DataInfoCacheService = null;
+ connection.MoreSettings.IsAutoRemoveDataCache = false;
+ using var db = new SqlSugarClient(connection);
+ db.Ado.CommandTimeOut = 10;
+ foreach (var table in new[] { "health_user", "health_ib43_binding" })
+ if (!db.DbMaintenance.IsAnyTable(table, false)) throw new InvalidOperationException("缺少业务表,请先启动新版后端。");
+ var runner = new Ib43LocalBinding(db, TimeProvider.System);
+ var actor = Environment.MachineName + "\\" + Environment.UserName;
+ var preview = await runner.Run(args[1], options, actor, false);
+ Console.WriteLine($"预检通过: userId={preview.UserId}, evergreen-test/IB43SIM001, left, simulation=true");
+ if (args.Length == 2) { Console.WriteLine("未修改数据库。加 --apply 执行绑定。"); return 0; }
+ if (args[2] == "--verify")
+ {
+ var repository = new Ib43DataRepository(db);
+ var binding = await repository.OwnedBinding(preview.UserId, preview.Id);
+ var hasAudit = await db.Queryable().Where(a => a.BindingId == binding.Id && a.UserId == binding.UserId).CountAsync() > 0;
+ var observations = await db.Queryable().Where(o => o.BindingId == binding.Id && o.UserId == binding.UserId).CountAsync();
+ var readings = await repository.Latest(binding.UserId, binding.Id);
+ var sampleMatches = readings.TryGetValue("heartRate", out var heart) && heart.Value == 72 &&
+ readings.TryGetValue("respiration", out var breath) && breath.Value == 16 &&
+ readings.TryGetValue("snoring", out var snoring) && snoring.Value == 0;
+ Console.WriteLine($"bindingId={binding.Id}, audit={hasAudit}, observations={observations}, expectedSample={sampleMatches}");
+ return hasAudit && observations > 0 && sampleMatches ? 0 : 2;
+ }
+ // 只维护新增审计表,不初始化/重建其他业务表,不启动 Web、MQTT 或种子数据。
+ if (!db.DbMaintenance.IsAnyTable("health_ib43_maintenance_audit", false))
+ db.CodeFirst.InitTables();
+ var result = await runner.Run(args[1], options, actor, true);
+ var verified = await db.Queryable().Where(b => b.Id == result.Id && b.UserId == result.UserId &&
+ b.Side == "left" && b.Simulation && b.EndedAtMs == null).CountAsync();
+ if (verified != 1) throw new InvalidOperationException("绑定读回验证失败。");
+ Console.WriteLine($"PASS: bindingId={result.Id}, userId={result.UserId}; 右侧未修改。重复运行复用已有绑定。");
+ return 0;
+}
+catch (InvalidOperationException error) { Console.Error.WriteLine(error.Message); return 1; }
+catch (Exception error)
+{
+ // 不打印第三方数据库异常正文,避免连接凭据进入控制台。
+ Console.Error.WriteLine($"维护失败 ({error.GetType().Name})。请检查数据库连接和表结构;未输出凭据。");
+ return 1;
+}