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; +}