diff --git a/Admin.NET.Test/Health/BedPresenceAutomationTests.cs b/Admin.NET.Test/Health/BedPresenceAutomationTests.cs index a51af46..3cf01e1 100644 --- a/Admin.NET.Test/Health/BedPresenceAutomationTests.cs +++ b/Admin.NET.Test/Health/BedPresenceAutomationTests.cs @@ -1,4 +1,7 @@ +using Evergreen.Health.Application.Health.Devices.Climate; using Evergreen.Health.Application.Health.Devices.Curtain; +using Evergreen.Health.Application.Health.Devices.Dehumidifier; +using Evergreen.Health.Application.Health.Devices.Environment; using Evergreen.Health.Application.Health.Devices.Lighting; using Evergreen.Health.Application.Health.Integrations.HomeAssistant; using Evergreen.Health.Application.Health.Scenes; @@ -16,6 +19,10 @@ public sealed class BedPresenceAutomationTests private const string Binding = "a98788f2aeeb40b68f145a182d99f832"; private const string Light = "light.test_bedroom"; private const string Curtain = "cover.test_curtain"; + private const string Dehumidifier = "humidifier.test"; + private const string Humidity = "sensor.test_humidity"; + private const string Climate = "climate.test"; + private const string Temperature = "sensor.test_temperature"; private static BedPresenceSample Sample(BedPresence presence, DateTimeOffset at, Guid connection) => new(Binding, "evergreen-test", "IB43SIM001", BedSide.Left, true, presence, at, connection); @@ -67,6 +74,158 @@ public sealed class BedPresenceAutomationTests finally { await automation.StopAsync(default); } } + [Fact] + public async Task Stopping_and_reopening_rule_cancels_the_old_action_waiting_for_persistence() + { + var time = new ManualTestTimeProvider(); + var options = Options.Create(new BedPresenceAutomationOptions + { Mode = BedAutomationMode.Managed, ActionKey = "curtain-close", TargetEntityId = Curtain }); + var action = new NeverExecuteAction(); + var executionLog = new PausedExecutionLog(); + using var automation = new BedPresenceAutomation(options, [action], time, NullLogger.Instance, executionLog); + await automation.StartAsync(default); + try + { + var bed = new BedAutomationBinding(Binding, 1, "evergreen-test", "IB43SIM001", BedSide.Left, true); + var connection = Guid.NewGuid(); + Assert.True(automation.TryEnable(bed)); + automation.Observe(Sample(BedPresence.Empty, time.GetUtcNow(), connection), default); + automation.Observe(Sample(BedPresence.Occupied, time.GetUtcNow(), connection), default); + await executionLog.Started.Task.WaitAsync(TimeSpan.FromSeconds(3)); + automation.Disable(Binding, 1); + Assert.True(automation.TryEnable(bed)); + executionLog.Release.TrySetResult("old-execution"); + Assert.Equal("cancelled-unconfirmed", await executionLog.Completed.Task.WaitAsync(TimeSpan.FromSeconds(3))); + Assert.Equal(0, action.Calls); + Assert.True(automation.IsEnabled(Binding, 1)); + } + finally + { + executionLog.Release.TrySetResult(null); + await automation.StopAsync(default); + } + } + + private sealed class PausedExecutionLog : IBedPresenceExecutionLog + { + public TaskCompletionSource Started { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously); + public TaskCompletionSource Release { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously); + public TaskCompletionSource Completed { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously); + public Task BeginAsync(string bindingId, long userId, string action, string target, DateTimeOffset triggeredAt, + CancellationToken token) + { Started.TrySetResult(); return Release.Task; } + public Task CompleteAsync(string id, string result, DateTimeOffset completedAt) + { Completed.TrySetResult(result); return Task.CompletedTask; } + } + + [Theory] + [InlineData("26", "°C", 0, false, "preview-climate-cool")] + [InlineData("21", "°C", 0, false, "preview-climate-heat")] + [InlineData("82.4", "°F", 0, false, "preview-climate-cool")] + [InlineData("22", "°C", 0, false, "preview-temperature-within-range")] + [InlineData("25", "°C", 0, false, "preview-temperature-within-range")] + [InlineData(null, "°C", 0, false, "preview-temperature-unavailable")] + [InlineData("26", "°C", 0, true, "preview-temperature-unavailable")] + [InlineData("26", "°C", 31, false, "preview-temperature-stale")] + [InlineData("26", "°C", null, false, "preview-temperature-stale")] + [InlineData("26", "%", 0, false, "preview-temperature-invalid")] + [InlineData("NaN", "°C", 0, false, "preview-temperature-invalid")] + public async Task Climate_preview_uses_the_default_range_and_never_sends_commands( + string? state, string unit, int? ageMinutes, bool readFails, string expected) + { + var clock = new ManualTestTimeProvider(); + var gateway = new FakeHaGateway { ReadsToFail = readFails ? 1 : 0 }; + if (state is not null) + gateway.States[Temperature] = new() + { + EntityId = Temperature, State = state, + LastReported = ageMinutes is { } age ? clock.GetUtcNow().AddMinutes(-age) : null, + Attributes = JsonSerializer.SerializeToElement(new { unit_of_measurement = unit }) + }; + var options = Options.Create(new HomeAssistantOptions { Entities = new() { Climate = Climate, Temperature = Temperature } }); + var preview = new BedPresenceClimatePreview(gateway, new IndoorEnvironmentReader(options, clock), options); + Assert.True(preview.PreviewOnly); + Assert.Equal("target-not-configured", await preview.ExecuteAsync("climate.other", default)); + Assert.Equal(expected, await preview.ExecuteAsync(Climate, default)); + Assert.Empty(gateway.Calls); + } + + [Theory] + [InlineData("64", "%", 0, "preview-would-start")] + [InlineData("55", "%", 0, "preview-humidity-normal")] + [InlineData("45", "%", 0, "preview-humidity-normal")] + [InlineData(null, "%", 0, "preview-humidity-unavailable")] + [InlineData("unavailable", "%", 0, "preview-humidity-unavailable")] + [InlineData("64", "%", 31, "preview-humidity-stale")] + [InlineData("64", "%", -2, "preview-humidity-stale")] + [InlineData("64", "%", null, "preview-humidity-stale")] + [InlineData("64", "°C", 0, "preview-humidity-invalid")] + [InlineData("NaN", "%", 0, "preview-humidity-invalid")] + [InlineData("101", "%", 0, "preview-humidity-invalid")] + public async Task Dehumidifier_preview_records_humidity_decision_without_sending_commands( + string? state, string unit, int? ageMinutes, string expected) + { + var clock = new ManualTestTimeProvider(); + var gateway = new FakeHaGateway(); + if (state is not null) + gateway.States[Humidity] = new() + { + EntityId = Humidity, State = state, + LastUpdated = ageMinutes is { } age ? clock.GetUtcNow().AddMinutes(-age) : null, + Attributes = JsonSerializer.SerializeToElement(new { unit_of_measurement = unit }) + }; + var options = Options.Create(new HomeAssistantOptions + { + Entities = new() { Dehumidifier = Dehumidifier, Humidity = Humidity }, + SleepScene = new() { TargetHumidity = 55 } + }); + var preview = new BedPresenceDehumidifierPreview(gateway, options, clock); + Assert.True(preview.PreviewOnly); + Assert.Equal("target-not-configured", await preview.ExecuteAsync("humidifier.other", default)); + Assert.Equal(expected, await preview.ExecuteAsync(Dehumidifier, default)); + Assert.Empty(gateway.Calls); + } + + [Theory] + [InlineData("64", 0, "off", "on", "readback-confirmed", 1)] + [InlineData("64", 0, "off", "off", "sent-readback-unconfirmed", 1)] + [InlineData("55", 0, "off", "on", "humidity-normal", 0)] + [InlineData("64", 31, "off", "on", "humidity-stale", 0)] + [InlineData(null, 0, "off", "on", "humidity-unavailable", 0)] + [InlineData("101", 0, "off", "on", "humidity-invalid", 0)] + [InlineData("64", 0, "on", "on", "dehumidifier-already-on", 0)] + [InlineData("64", 0, "unavailable", "on", "dehumidifier-unavailable", 0)] + [InlineData("64", 0, "unknown-state", "on", "dehumidifier-state-not-off", 0)] + public async Task Dehumidifier_rule_requires_fresh_high_humidity_and_off_state_and_never_resends( + string? humidity, int ageMinutes, string power, string readback, string result, int commandCount) + { + var clock = new ManualTestTimeProvider(); + var gateway = new FakeHaGateway { HumidifierTurnOnResult = readback }; + gateway.States[Dehumidifier] = new() { EntityId = Dehumidifier, State = power }; + if (humidity is not null) + gateway.States[Humidity] = new() { EntityId = Humidity, State = humidity, + LastReported = clock.GetUtcNow().AddMinutes(-ageMinutes), + Attributes = JsonSerializer.SerializeToElement(new { unit_of_measurement = "%" }) }; + var options = Options.Create(new HomeAssistantOptions + { + Entities = new() { Dehumidifier = Dehumidifier, Humidity = Humidity }, + SleepScene = new() { TargetHumidity = 55 } + }); + var runner = new DeviceActionRunner(gateway, clock, new() { VerificationDelay = TimeSpan.Zero }); + var action = new BedPresenceDehumidifierStartAction(new DehumidifierModule(runner, options), runner, options, clock); + + Assert.Equal("target-not-configured", await action.ExecuteAsync("humidifier.other", default)); + Assert.Equal(result, await action.ExecuteAsync(Dehumidifier, default)); + Assert.Equal(commandCount, gateway.Calls.Count); + Assert.All(gateway.Calls, command => + { + Assert.Equal(Dehumidifier, command.EntityId); + Assert.Equal("humidifier", command.Domain); + Assert.Equal("turn_on", command.Service); + Assert.Equal("entity_id", Assert.Single(command.Data!).Key); + }); + } + [Theory] [InlineData("closed", "already-closed")] [InlineData("closing", "already-closing")] diff --git a/Admin.NET.Test/Health/ClimateCurtainIntegrationTests.cs b/Admin.NET.Test/Health/ClimateCurtainIntegrationTests.cs index 9083f0a..fed8c75 100644 --- a/Admin.NET.Test/Health/ClimateCurtainIntegrationTests.cs +++ b/Admin.NET.Test/Health/ClimateCurtainIntegrationTests.cs @@ -3,6 +3,7 @@ using Evergreen.Health.Application.Health.Devices.Climate; using Evergreen.Health.Application.Health.Devices.Contracts; using Evergreen.Health.Application.Health.Devices.Curtain; using Evergreen.Health.Application.Health.Devices.Discovery; +using Evergreen.Health.Application.Health.Devices.Environment; using Evergreen.Health.Application.Health.Integrations.HomeAssistant; using Evergreen.Health.Application.Health.Models; using Evergreen.Health.Application.Health.Scenes; @@ -16,11 +17,25 @@ namespace Admin.NET.Test.Health; public sealed class ClimateCurtainIntegrationTests { private const string ClimateId = "climate.test_room", CurtainId = "cover.test_curtain"; - private static HomeAssistantOptions Settings() => new() { Entities = new() { Climate = ClimateId, Curtain = CurtainId } }; + private const string RoomTemperatureId = "sensor.test_room_temperature"; + private static HomeAssistantOptions Settings() => new() + { Entities = new() { Climate = ClimateId, Curtain = CurtainId, Temperature = RoomTemperatureId } }; private static DeviceActionRunner Runner(Gateway gateway) => new(gateway, TimeProvider.System, new() { VerificationDelay = TimeSpan.Zero, VerificationAttempts = 3 }); private static ClimateModule Climate(Gateway gateway) => new(Runner(gateway), Options.Create(Settings())); private static CurtainModule Curtain(Gateway gateway) => new(Runner(gateway), Options.Create(Settings())); + private static BedPresenceClimateHeatAction HeatAutomation(Gateway gateway) + { + var runner = Runner(gateway); + var options = Options.Create(Settings()); + return new(new ClimateModule(runner, options), new IndoorEnvironmentReader(options, TimeProvider.System), runner, options); + } + + private static HomeAssistantState RoomTemperature(string celsius = "20", int ageMinutes = 0) => new() + { + EntityId = RoomTemperatureId, State = celsius, LastReported = DateTimeOffset.UtcNow.AddMinutes(-ageMinutes), + Attributes = JsonSerializer.SerializeToElement(new { unit_of_measurement = "°C" }) + }; private static HomeAssistantState ClimateState(string state = "off", int features = 393, double minimum = 5, double maximum = 35, double step = .5) => new() { @@ -43,6 +58,64 @@ public sealed class ClimateCurtainIntegrationTests TargetTemperatureCelsius = target, Reason = "测试室温建议" } }; + [Fact] + public async Task Bed_presence_heat_sends_each_command_once_and_confirms_heat_and_22_degrees() + { + var gateway = new Gateway(ClimateState(), RoomTemperature()); + var action = HeatAutomation(gateway); + Assert.Equal("target-not-configured", await action.ExecuteAsync("climate.other", default)); + Assert.Equal("readback-confirmed", await action.ExecuteAsync(ClimateId, default)); + Assert.Collection(gateway.Calls, + mode => { Assert.Equal("set_hvac_mode", mode.Service); Assert.Equal("heat", mode.Data!["hvac_mode"]); }, + temperature => { Assert.Equal("set_temperature", temperature.Service); Assert.Equal(22m, temperature.Data!["temperature"]); }); + Assert.Equal("heat", gateway.States[ClimateId].State); + Assert.Equal(22, gateway.States[ClimateId].GetDoubleAttribute("temperature")); + } + + [Theory] + [InlineData("22", 0, "off", 5, "temperature-not-low")] + [InlineData("27", 0, "off", 5, "temperature-not-low")] + [InlineData("20", 31, "off", 5, "temperature-stale")] + [InlineData("NaN", 0, "off", 5, "temperature-invalid")] + [InlineData(null, 0, "off", 5, "temperature-unavailable")] + [InlineData("20", 0, "cool", 5, "climate-not-off")] + [InlineData("20", 0, "unavailable", 5, "climate-unavailable")] + [InlineData("20", 0, "off", 24, "climate-heat-unsupported")] + public async Task Bed_presence_heat_checks_room_power_and_target_before_sending( + string? roomTemperature, int ageMinutes, string climateState, int minimum, string result) + { + var gateway = new Gateway(ClimateState(climateState, minimum: minimum)); + if (roomTemperature is not null) gateway.States[RoomTemperatureId] = RoomTemperature(roomTemperature, ageMinutes); + Assert.Equal(result, await HeatAutomation(gateway).ExecuteAsync(ClimateId, default)); + Assert.Empty(gateway.Calls); + } + + [Fact] + public async Task Bed_presence_heat_leaves_the_temperature_alone_when_heat_mode_cannot_be_confirmed() + { + var gateway = new Gateway(ClimateState(), RoomTemperature()) { ApplyCommands = false }; + Assert.Equal("climate-heat-mode-unconfirmed", await HeatAutomation(gateway).ExecuteAsync(ClimateId, default)); + Assert.Equal("set_hvac_mode", Assert.Single(gateway.Calls).Service); + } + + [Fact] + public async Task Bed_presence_heat_does_not_set_temperature_after_the_user_changes_the_mode() + { + var gateway = new Gateway(ClimateState(), RoomTemperature()); + gateway.BeforeRead = read => { if (read == 3) gateway.States[ClimateId] = ClimateState("off"); }; + Assert.Equal("climate-heat-state-changed", await HeatAutomation(gateway).ExecuteAsync(ClimateId, default)); + Assert.Equal("set_hvac_mode", Assert.Single(gateway.Calls).Service); + } + + [Fact] + public async Task Bed_presence_heat_requires_both_mode_and_target_in_the_final_readback() + { + var gateway = new Gateway(ClimateState(), RoomTemperature()) { ModeAfterTemperature = "off" }; + Assert.Equal("climate-heat-target-unconfirmed", await HeatAutomation(gateway).ExecuteAsync(ClimateId, default)); + Assert.Equal(22, gateway.States[ClimateId].GetDoubleAttribute("temperature")); + Assert.Equal(new[] { "set_hvac_mode", "set_temperature" }, gateway.Calls.Select(command => command.Service)); + } + [Theory] [InlineData(5)] [InlineData(23.5)] @@ -383,9 +456,12 @@ public sealed class ClimateCurtainIntegrationTests public int Reads { get; private set; } public bool ApplyCommands { get; set; } = true; public bool BecomeUnavailableAfterSend { get; set; } + public string? ModeAfterTemperature { get; set; } + public Action? BeforeRead { get; set; } public Task> GetStatesAsync(CancellationToken cancellationToken = default) { cancellationToken.ThrowIfCancellationRequested(); Reads++; + BeforeRead?.Invoke(Reads); return Task.FromResult>(States.Values.ToArray()); } public Task CallServiceAsync(string domain, string service, IReadOnlyDictionary data, @@ -397,7 +473,14 @@ public sealed class ClimateCurtainIntegrationTests if (!ApplyCommands) return Task.CompletedTask; var previous = States[entityId]; var attributes = previous.Attributes.EnumerateObject().ToDictionary(item => item.Name, item => (object?)item.Value.Clone()); - var current = service switch { "set_hvac_mode" => (string)data["hvac_mode"]!, "turn_on" => "heat", "turn_off" => "off", _ => previous.State }; + var current = service switch + { + "set_hvac_mode" => (string)data["hvac_mode"]!, + "set_temperature" when ModeAfterTemperature is not null => ModeAfterTemperature, + "turn_on" => "heat", + "turn_off" => "off", + _ => previous.State + }; if (data.TryGetValue("temperature", out var temperature)) attributes["temperature"] = temperature; if (data.TryGetValue("fan_mode", out var fan)) attributes["fan_mode"] = fan; States[entityId] = new() { EntityId = entityId, State = BecomeUnavailableAfterSend ? "unavailable" : current, diff --git a/Admin.NET.Test/Health/DeviceArchitectureTests.cs b/Admin.NET.Test/Health/DeviceArchitectureTests.cs index 5d3ad6d..471f2ee 100644 --- a/Admin.NET.Test/Health/DeviceArchitectureTests.cs +++ b/Admin.NET.Test/Health/DeviceArchitectureTests.cs @@ -193,6 +193,53 @@ public sealed class DeviceArchitectureTests Assert.Empty(gateway.Calls); } + [Fact] + public async Task FragranceManualControlsUseTheirOwnDeviceAndKeepWakeOutOfSleepScenes() + { + var services = new ServiceCollection(); + services.AddLogging(); services.AddOptions(); services.AddHttpClient(); services.AddHealthDevices(); + var gateway = new FakeHaGateway(); + services.AddSingleton(gateway); + services.AddSingleton(Options.Create(new HomeAssistantOptions + { + OnlyConfiguredDevices = true, + Entities = new() + { + FragranceSwitch = "switch.sleep_fragrance", FragranceDuration = "number.sleep_duration", + FragranceQuickRelease = "button.sleep_release", + WakeFragranceSwitch = "switch.wake_fragrance", WakeFragranceDuration = "number.wake_duration", + WakeFragranceQuickRelease = "button.wake_release" + } + })); + using var provider = services.BuildServiceProvider(); + var registry = provider.GetRequiredService(); + var catalog = provider.GetRequiredService(); + var parameters = provider.GetRequiredService(); + var sleep = Assert.Single(registry.Modules, module => module.Key == "fragrance"); + var wake = Assert.Single(registry.Modules, module => module.Key == "wake-fragrance"); + foreach (var id in sleep.EntityIds.Concat(wake.EntityIds)) + gateway.States[id] = new() { EntityId = id, State = "off" }; + var inventory = catalog.BuildDiscoveredDevices(null, gateway.States); + var sleepDevice = Assert.Single(inventory, device => device.ModuleKey == sleep.Key); + var wakeDevice = Assert.Single(inventory, device => device.ModuleKey == wake.Key); + Assert.Equal("switch.wake_fragrance", wakeDevice.PrimaryControlEntityId); + await wake.SetPowerAsync(true, default); + await parameters.ExecuteAsync(wakeDevice, gateway.States, "wake_fragrance_duration", 5, null, default); + await parameters.ExecuteAsync(wakeDevice, gateway.States, "wake_fragrance_quick_release", true, null, default); + await parameters.ExecuteAsync(sleepDevice, gateway.States, "fragrance_duration", 3, null, default); + await Assert.ThrowsAsync(() => parameters.ExecuteAsync( + wakeDevice, gateway.States, "fragrance_quick_release", true, null, default)); + await Assert.ThrowsAsync(() => parameters.ExecuteAsync( + sleepDevice, gateway.States, "wake_fragrance_quick_release", true, null, default)); + Assert.Equal(new[] { "switch.wake_fragrance", "number.wake_duration", "button.wake_release", "number.sleep_duration" }, + gateway.Calls.Select(command => command.EntityId)); + Assert.Null(wake.BuildPreview(new(), "balanced")); + Assert.Empty(wake.GetShutdownCommands()); + var context = new DeviceSceneContext(Runner(gateway), gateway.States, []); + await wake.ExecuteSleepAsync(context, new() { EnableFragrance = true }, "balanced", default); + Assert.Equal(4, gateway.Calls.Count); + } + [Fact] public async Task ShutdownContinuesAfterAnIndividualDeviceFailure() { diff --git a/Admin.NET.Test/Health/DeviceTestDoubles.cs b/Admin.NET.Test/Health/DeviceTestDoubles.cs index 1e22b85..9fbf957 100644 --- a/Admin.NET.Test/Health/DeviceTestDoubles.cs +++ b/Admin.NET.Test/Health/DeviceTestDoubles.cs @@ -16,6 +16,7 @@ internal sealed class FakeHaGateway : IHomeAssistantGateway public string? FailEntity { get; set; } public int ReadsToFail { get; set; } public string? CoverCloseResult { get; set; } + public string? HumidifierTurnOnResult { get; set; } public TimeProvider Time { get; set; } = TimeProvider.System; public Task> GetStatesAsync(CancellationToken cancellationToken = default) @@ -34,6 +35,7 @@ internal sealed class FakeHaGateway : IHomeAssistantGateway if (entity == FailEntity) throw new IOException("模拟设备命令失败"); var state = service switch { + "turn_on" when domain == "humidifier" && HumidifierTurnOnResult is not null => HumidifierTurnOnResult, "turn_on" => "on", "turn_off" => "off", "close_cover" when CoverCloseResult is not null => CoverCloseResult, "select_option" => data["option"]!.ToString()!, diff --git a/Admin.NET.Test/Health/Ib43MqttIntegrationTests.cs b/Admin.NET.Test/Health/Ib43MqttIntegrationTests.cs index ebf8236..8005602 100644 --- a/Admin.NET.Test/Health/Ib43MqttIntegrationTests.cs +++ b/Admin.NET.Test/Health/Ib43MqttIntegrationTests.cs @@ -1,7 +1,13 @@ using Evergreen.Health.Application.Health.Integrations.Ib43; using Evergreen.Health.Application.Health.Entities; +using Evergreen.Health.Application.Health.Devices.Audio; +using Evergreen.Health.Application.Health.Devices.Climate; using Evergreen.Health.Application.Health.Devices.Curtain; +using Evergreen.Health.Application.Health.Devices.Dehumidifier; +using Evergreen.Health.Application.Health.Devices.Environment; +using Evergreen.Health.Application.Health.Devices.Fragrance; using Evergreen.Health.Application.Health.Integrations.HomeAssistant; +using Evergreen.Health.Application.Health.Integrations.MiAirX; using Evergreen.Health.Application.Health.Scenes; using Evergreen.Health.Application.Health.Sleep; using SqlSugar; @@ -13,6 +19,7 @@ using MQTTnet.Protocol; using MQTTnet.Server; using System.Net; using System.Net.Sockets; +using System.Collections.Concurrent; using System.Text.Json.Nodes; using System.Text.Json; using Xunit; @@ -23,9 +30,17 @@ public sealed class Ib43MqttIntegrationTests { private const string LeftBinding = "a98788f2aeeb40b68f145a182d99f832"; private const string TargetCurtain = "cover.test_curtain"; + private const string TargetDehumidifier = "humidifier.test"; + private const string HumiditySensor = "sensor.test_humidity"; + private const string TargetClimate = "climate.test"; + private const string TemperatureSensor = "sensor.test_temperature"; + private const string FragranceButton = "button.test_fragrance"; + private const string WakeFragranceButton = "button.test_wake_fragrance"; + private const string Speaker = "media_player.test_speaker"; + private const string TrackUrl = "https://example.test/sleep.mp3"; [Fact] - public async Task Real_MQTT_receives_supplier_examples_and_resubscribes_after_broker_restart() + public async Task Real_MQTT_resubscribes_and_only_fresh_empty_to_occupied_triggers_after_broker_restart() { // 只监听 loopback,随机端口;不访问用户的 EMQX、数据库或家具。 using var reservation = new TcpListener(IPAddress.Loopback, 0); @@ -43,17 +58,55 @@ public sealed class Ib43MqttIntegrationTests 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", + var openCurtain = new HomeAssistantState { EntityId = TargetCurtain, State = "open", Attributes = JsonSerializer.SerializeToElement(new { supported_features = 2 }) }; - var haOptions = Options.Create(new HomeAssistantOptions { Entities = new() { Curtain = TargetCurtain } }); + var stoppedDehumidifier = new HomeAssistantState { EntityId = TargetDehumidifier, State = "off" }; + gateway.States[TargetCurtain] = openCurtain; + gateway.States[TargetDehumidifier] = stoppedDehumidifier; + gateway.States[HumiditySensor] = new() { EntityId = HumiditySensor, State = "64", LastReported = DateTimeOffset.UtcNow, + Attributes = JsonSerializer.SerializeToElement(new { unit_of_measurement = "%" }) }; + gateway.States[TemperatureSensor] = new() { EntityId = TemperatureSensor, State = "27", LastReported = DateTimeOffset.UtcNow, + Attributes = JsonSerializer.SerializeToElement(new { unit_of_measurement = "°C" }) }; + gateway.States[FragranceButton] = new() { EntityId = FragranceButton, State = "unknown" }; + gateway.States[WakeFragranceButton] = new() { EntityId = WakeFragranceButton, State = "unknown" }; + gateway.States[Speaker] = new() { EntityId = Speaker, State = "idle" }; + var haOptions = Options.Create(new HomeAssistantOptions + { Entities = new() { Curtain = TargetCurtain, Dehumidifier = TargetDehumidifier, Humidity = HumiditySensor, + Climate = TargetClimate, Temperature = TemperatureSensor, FragranceQuickRelease = FragranceButton, + WakeFragranceQuickRelease = WakeFragranceButton, Speaker = Speaker } }); 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); + var preview = new BedPresenceDehumidifierPreview(gateway, haOptions, TimeProvider.System); + var dehumidifier = new BedPresenceDehumidifierStartAction(new DehumidifierModule(runner, haOptions), + runner, haOptions, TimeProvider.System); + var room = new IndoorEnvironmentReader(haOptions, TimeProvider.System); + var climate = new BedPresenceClimatePreview(gateway, room, haOptions); + var heat = new BedPresenceClimateHeatAction(new ClimateModule(runner, haOptions), room, runner, haOptions); + var fragrance = new BedPresenceFragranceReleaseAction(runner, haOptions, wakeDevice: false); + var wakeFragrance = new BedPresenceFragranceReleaseAction(runner, haOptions, wakeDevice: true); + var audio = new FakeAudioGateway(); + var music = new TcmMusicTransitionService(audio, Options.Create(new TcmMusicOptions + { + Tracks = new() { ["balanced"] = [new() { Url = TrackUrl, DurationSeconds = 30 }] } + }), NullLogger.Instance, new ManualTestTimeProvider()); + var speaker = new BedPresenceSleepMusicAction(gateway, music, haOptions); + var executionLog = new RecordingExecutionLog(); using var automation = new BedPresenceAutomation(Options.Create(new BedPresenceAutomationOptions - { Mode = BedAutomationMode.Execute, BindingId = LeftBinding, ActionKey = "curtain-close", TargetEntityId = TargetCurtain }), - [curtainAction], TimeProvider.System, NullLogger.Instance); + { + Mode = BedAutomationMode.Managed, ActionKey = curtainAction.Key, TargetEntityId = TargetCurtain, + AdditionalRules = [new() { Mode = BedAutomationMode.Managed, ActionKey = preview.Key, TargetEntityId = TargetDehumidifier }, + new() { Mode = BedAutomationMode.Managed, ActionKey = dehumidifier.Key, TargetEntityId = TargetDehumidifier }, + new() { Mode = BedAutomationMode.Managed, ActionKey = climate.Key, TargetEntityId = TargetClimate }, + new() { Mode = BedAutomationMode.Managed, ActionKey = heat.Key, TargetEntityId = TargetClimate }, + new() { Mode = BedAutomationMode.Managed, ActionKey = fragrance.Key, TargetEntityId = FragranceButton }, + new() { Mode = BedAutomationMode.Managed, ActionKey = wakeFragrance.Key, TargetEntityId = WakeFragranceButton }, + new() { Mode = BedAutomationMode.Managed, ActionKey = speaker.Key, TargetEntityId = Speaker }] + }), [curtainAction, preview, dehumidifier, climate, heat, fragrance, wakeFragrance, speaker], TimeProvider.System, + NullLogger.Instance, executionLog); + var observedPresence = new ObservedPresenceSink(automation); using var service = new Ib43MqttService(options, new Ib43MqttTransportFactory(), store, - TimeProvider.System, NullLogger.Instance, sink, automation); + TimeProvider.System, NullLogger.Instance, sink, observedPresence); using var publisher = factory.CreateMqttClient(); try { @@ -79,42 +132,209 @@ public sealed class Ib43MqttIntegrationTests 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, 1, 0); + await WaitFor(() => Volatile.Read(ref sink.SavedReports) == 4); + Assert.False(automation.IsEnabled(LeftBinding, 1)); + Assert.Empty(gateway.Calls); + var bed = new BedAutomationBinding(LeftBinding, 1, binding.ProductKey, binding.SerialNumber, BedSide.Left, true); + Assert.True(automation.TryEnable(bed)); + // 左侧空床后上床才触发。连续重复在床不会重启,右侧的变化不触发左侧规则。 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); + await WaitFor(() => Volatile.Read(ref sink.SavedReports) == 8 && gateway.Calls.Count == 1); + Assert.Equal("readback-confirmed", await executionLog.WaitForResult(curtainAction.Key)); 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); + automation.Disable(LeftBinding, 1); + Assert.True(automation.TryEnable(bed, preview.Key)); + Assert.False(automation.IsEnabled(LeftBinding, 1)); + 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) == 11); + Assert.Equal("preview-would-start", await executionLog.WaitForResult(preview.Key)); + Assert.Single(gateway.Calls); + Assert.Equal("off", gateway.States[TargetDehumidifier].State); + Assert.True(automation.Disable(LeftBinding, 1, preview.Key)); + + Assert.True(automation.TryEnable(bed, dehumidifier.Key)); + 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) == 14); + Assert.Equal("readback-confirmed", await executionLog.WaitForResult(dehumidifier.Key)); + Assert.Equal("on", gateway.States[TargetDehumidifier].State); + + gateway.States[TargetCurtain] = openCurtain; + gateway.States[TargetDehumidifier] = stoppedDehumidifier; + Assert.True(automation.TryEnable(bed)); + await PublishPresence(publisher, binding, 0, 1); + await WaitFor(() => observedPresence.LastLeft is { Presence: BedPresence.Empty }); + var oldConnection = observedPresence.LastLeft!.ConnectionId; + + // 保持两条实物规则开启,断开前的空床基线不能与新连接的首条在床组成上床事件。 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()); + Assert.True(automation.IsEnabled(LeftBinding, 1)); + Assert.True(automation.IsEnabled(LeftBinding, 1, dehumidifier.Key)); + await PublishPresence(publisherAfterRestart, binding, 1, 1); + await WaitFor(() => observedPresence.LastLeft is { Presence: BedPresence.Occupied }); + var firstOnNewConnection = observedPresence.LastLeft!; + Assert.NotEqual(oldConnection, firstOnNewConnection.ConnectionId); + Assert.Equal(3, executionLog.Started.Count); + Assert.Equal(2, gateway.Calls.Count); + + await PublishPresence(publisherAfterRestart, binding, 0, 1); + await WaitFor(() => observedPresence.LastLeft is { Presence: BedPresence.Empty }); + await PublishPresence(publisherAfterRestart, binding, 1, 1); + await WaitFor(() => observedPresence.LastLeft is { Presence: BedPresence.Occupied }); + var freshTransition = observedPresence.LastLeft!; + Assert.Equal(firstOnNewConnection.ConnectionId, freshTransition.ConnectionId); + await PublishPresence(publisherAfterRestart, binding, 1, 1); + await WaitFor(() => Volatile.Read(ref sink.SavedReports) == 19 && executionLog.Completed.Count >= 5); + 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); + await WaitFor(() => Volatile.Read(ref sink.SavedReports) == 20); Assert.Equal(75d, (await sink.Repository.Latest(1, LeftBinding))["heartRate"].Value); - Assert.Single(gateway.Calls); + var newExecutions = executionLog.Started.Skip(3).ToArray(); + Assert.Equal(new[] { curtainAction.Key, dehumidifier.Key }, + newExecutions.Select(entry => entry.Action).OrderBy(key => key, StringComparer.Ordinal)); + Assert.All(newExecutions, entry => Assert.Equal(freshTransition.ReceivedAt, entry.TriggeredAt)); + Assert.All(executionLog.Completed.Skip(3), result => Assert.Equal("readback-confirmed", result)); + Assert.Equal(5, executionLog.Completed.Count); + Assert.Equal(new[] { "close_cover", "turn_on" }, + gateway.Calls.Skip(2).Select(command => command.Service).OrderBy(name => name, StringComparer.Ordinal)); + + Assert.True(automation.Disable(LeftBinding, 1)); + Assert.True(automation.Disable(LeftBinding, 1, dehumidifier.Key)); + Assert.False(automation.IsEnabled(LeftBinding, 1, climate.Key)); + Assert.True(automation.TryEnable(bed, climate.Key)); + await PublishPresence(publisherAfterRestart, binding, 0, 1); + await PublishPresence(publisherAfterRestart, binding, 1, 1); + await PublishPresence(publisherAfterRestart, binding, 1, 1); + await WaitFor(() => Volatile.Read(ref sink.SavedReports) == 23); + Assert.Equal("preview-climate-cool", await executionLog.WaitForResult(climate.Key)); + Assert.Equal(climate.Key, Assert.Single(executionLog.Started.Skip(5)).Action); + Assert.Equal(4, gateway.Calls.Count); + + Assert.True(automation.Disable(LeftBinding, 1, climate.Key)); + Assert.False(automation.IsEnabled(LeftBinding, 1, heat.Key)); + gateway.States[TargetCurtain] = openCurtain; + gateway.States[TargetDehumidifier] = stoppedDehumidifier; + gateway.States[TemperatureSensor] = new() { EntityId = TemperatureSensor, State = "20", LastReported = DateTimeOffset.UtcNow, + Attributes = JsonSerializer.SerializeToElement(new { unit_of_measurement = "°C" }) }; + gateway.States[TargetClimate] = new() { EntityId = TargetClimate, State = "off", + Attributes = JsonSerializer.SerializeToElement(new { hvac_modes = new[] { "off", "heat", "cool" }, supported_features = 1, + temperature_unit = "°C", min_temp = 16, max_temp = 30, target_temp_step = 1, temperature = 24 }) }; + Assert.True(automation.TryEnable(bed)); + Assert.True(automation.TryEnable(bed, dehumidifier.Key)); + Assert.True(automation.TryEnable(bed, heat.Key)); + foreach (var action in new[] { fragrance.Key, wakeFragrance.Key, speaker.Key }) + { + Assert.False(automation.IsEnabled(LeftBinding, 1, action)); + Assert.True(automation.TryEnable(bed, action)); + } + await PublishPresence(publisherAfterRestart, binding, 0, 1); + await WaitFor(() => observedPresence.LastLeft is { Presence: BedPresence.Empty }); + await PublishPresence(publisherAfterRestart, binding, 1, 1); + await WaitFor(() => observedPresence.LastLeft is { Presence: BedPresence.Occupied }); + var combinedTransition = observedPresence.LastLeft!; + await PublishPresence(publisherAfterRestart, binding, 1, 1); + await WaitFor(() => Volatile.Read(ref sink.SavedReports) == 26 && executionLog.Completed.Count >= 12); + var combinedExecutions = executionLog.Started.Skip(6).ToArray(); + Assert.Equal(new[] { heat.Key, curtainAction.Key, dehumidifier.Key, fragrance.Key, speaker.Key, wakeFragrance.Key }, + combinedExecutions.Select(entry => entry.Action).OrderBy(key => key, StringComparer.Ordinal)); + Assert.All(combinedExecutions, entry => Assert.Equal(combinedTransition.ReceivedAt, entry.TriggeredAt)); + var combinedResults = executionLog.Completed.Skip(6).ToArray(); + Assert.Equal(6, combinedResults.Length); + Assert.Equal(3, combinedResults.Count(result => result == "readback-confirmed")); + Assert.Equal("fragrance-release-sent", await executionLog.WaitForResult(fragrance.Key)); + Assert.Equal("fragrance-release-sent", await executionLog.WaitForResult(wakeFragrance.Key)); + Assert.Equal("speaker-playback-sent", await executionLog.WaitForResult(speaker.Key)); + + var combinedCommands = gateway.Calls.Skip(4).ToArray(); + Assert.Equal(new[] { "close_cover", "press", "press", "set_hvac_mode", "set_temperature", "turn_on" }, + combinedCommands.Select(command => command.Service).OrderBy(name => name, StringComparer.Ordinal)); + Assert.Equal(new[] { FragranceButton, WakeFragranceButton }, combinedCommands.Where(command => command.Service == "press") + .Select(command => command.EntityId).OrderBy(id => id, StringComparer.Ordinal)); + Assert.Equal(TrackUrl, Assert.Single(audio.Tracks)); + Assert.Equal(50, Assert.Single(audio.Volumes)); + Assert.Equal(new[] { "set_hvac_mode", "set_temperature" }, + combinedCommands.Where(command => command.EntityId == TargetClimate).Select(command => command.Service)); + Assert.Equal("closed", gateway.States[TargetCurtain].State); + Assert.Equal("on", gateway.States[TargetDehumidifier].State); + Assert.Equal("heat", gateway.States[TargetClimate].State); + Assert.Equal(22, gateway.States[TargetClimate].GetDoubleAttribute("temperature")); + + Assert.Equal("speaker-not-idle", await speaker.ExecuteAsync(Speaker, default)); + Assert.Single(audio.Tracks); + gateway.States[WakeFragranceButton] = new() { EntityId = WakeFragranceButton, State = "unavailable" }; + Assert.Equal("fragrance-release-unavailable", await wakeFragrance.ExecuteAsync(WakeFragranceButton, default)); + Assert.Equal("target-not-configured", await fragrance.ExecuteAsync(WakeFragranceButton, default)); + Assert.Equal(10, gateway.Calls.Count); } finally { using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(5)); await service.StopAsync(timeout.Token); await automation.StopAsync(timeout.Token); + await music.StopAsync(timeout.Token); await broker.StopAsync(); } } + private sealed class ObservedPresenceSink(IBedPresenceSink inner) : IBedPresenceSink + { + private BedPresenceSample? _lastLeft; + public BedPresenceSample? LastLeft => Volatile.Read(ref _lastLeft); + + public void Observe(BedPresenceSample sample, CancellationToken connectionToken) + { + inner.Observe(sample, connectionToken); + if (sample.Side == BedSide.Left) Volatile.Write(ref _lastLeft, sample); + } + } + + private sealed class RecordingExecutionLog : IBedPresenceExecutionLog + { + private readonly ConcurrentDictionary> _results = new(); + public ConcurrentQueue<(string Action, DateTimeOffset TriggeredAt)> Started { get; } = new(); + public ConcurrentQueue Completed { get; } = new(); + public Task WaitForResult(string action) => Result(action).Task.WaitAsync(TimeSpan.FromSeconds(3)); + + private TaskCompletionSource Result(string action) => + _results.GetOrAdd(action, _ => new(TaskCreationOptions.RunContinuationsAsynchronously)); + public Task BeginAsync(string bindingId, long userId, string action, string target, DateTimeOffset triggeredAt, CancellationToken token) + { + Assert.Equal(LeftBinding, bindingId); + Assert.Equal(1L, userId); + Started.Enqueue((action, triggeredAt)); + return Task.FromResult(action); + } + public Task CompleteAsync(string id, string result, DateTimeOffset completedAt) + { + Completed.Enqueue(result); + Result(id).TrySetResult(result); + return Task.CompletedTask; + } + } + private sealed class DatabaseSink : IIb43ReportSink, IDisposable { private readonly SqlSugarClient _db; diff --git a/Admin.NET.Test/Health/Ib43PersistenceTests.cs b/Admin.NET.Test/Health/Ib43PersistenceTests.cs index 78618a0..096c39f 100644 --- a/Admin.NET.Test/Health/Ib43PersistenceTests.cs +++ b/Admin.NET.Test/Health/Ib43PersistenceTests.cs @@ -2,7 +2,9 @@ using Admin.NET.Core; using Evergreen.Health.Application.Health.Entities; using Evergreen.Health.Application.Health.Integrations.Ib43; using Evergreen.Health.Application.Health.Services; +using Evergreen.Health.Application.Health.Sleep; using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Options; using SqlSugar; using System.Security.Claims; @@ -12,6 +14,7 @@ namespace Admin.NET.Test.Health; public sealed class Ib43PersistenceTests : IDisposable { + private const string PreviewAction = "dehumidifier-humidity-preview"; private readonly SqlSugarClient _db; private readonly string _databasePath = Path.Combine(Path.GetTempPath(), "ib43-test-" + Guid.NewGuid().ToString("N") + ".db"); private readonly Ib43DataRepository _repository; @@ -19,6 +22,7 @@ public sealed class Ib43PersistenceTests : IDisposable private readonly Ib43Binding _device = new() { ProductKey = "test", SerialNumber = "sim", Simulation = true }; private readonly HttpContextAccessor _context = new() { HttpContext = new DefaultHttpContext() }; private readonly HealthIb43Service _service; + private readonly BedPresenceAutomation _automation; private sealed class Clock : TimeProvider { @@ -37,14 +41,31 @@ public sealed class Ib43PersistenceTests : IDisposable if (Nullable.GetUnderlyingType(property.PropertyType) != null) column.IsNullable = true; } } }); _db.CodeFirst.InitTables(); + _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); + _automation = CreateAutomation(_repository, PreviewAction); + _service = CreateService(_repository, _automation); + } + + private BedPresenceAutomation CreateAutomation(Ib43DataRepository repository, string secondaryAction) => + new(Options.Create(new BedPresenceAutomationOptions + { + Mode = BedAutomationMode.Managed, ProductKey = _device.ProductKey, SerialNumber = _device.SerialNumber, + ActionKey = "curtain-close", TargetEntityId = "cover.test", TargetLabel = "测试窗帘", + AdditionalRules = [new() { Mode = BedAutomationMode.Managed, ProductKey = _device.ProductKey, + SerialNumber = _device.SerialNumber, ActionKey = secondaryAction, TargetEntityId = "humidifier.test" }] + }), [new TestAction("curtain-close", "cover.test"), new TestAction(secondaryAction, "humidifier.test")], + _clock, NullLogger.Instance, new TestExecutionLog(repository)); + + private HealthIb43Service CreateService(Ib43DataRepository repository, BedPresenceAutomation automation) + { + var options = Options.Create(new Ib43Options { Enabled = true, Devices = [_device] }); + return new(repository, _context, options, new(options, new(), _clock), _clock, automation); } private void User(long id) => _context.HttpContext.User = new ClaimsPrincipal(new ClaimsIdentity( @@ -166,6 +187,121 @@ public sealed class Ib43PersistenceTests : IDisposable Assert.Equal("Furion.FriendlyException.AppFriendlyException", error.GetType().FullName); } + [Fact] + public async Task Owner_controls_only_the_configured_bed_and_can_read_the_durable_execution_result() + { + var left = await Assign("left", 1); + var right = await Assign("right", 2); + await _automation.StartAsync(default); + try + { + User(1); + var state = await _service.AutomationStatus(left.Id); + Assert.True(state.Available); + Assert.False(state.Enabled); + Assert.Equal(new[] { "curtain-close", PreviewAction }, state.Rules.Select(rule => rule.ActionKey)); + await _service.StartAutomation(left.Id); + Assert.True((await _service.AutomationStatus(left.Id)).Enabled); + await _service.StartAutomation(left.Id, PreviewAction); + Assert.All((await _service.AutomationStatus(left.Id)).Rules, rule => Assert.True(rule.Enabled)); + await ExpectRejection(() => _service.StartAutomation(left.Id, "unknown")); + await ExpectRejection(() => _service.StopAutomation(left.Id, "unknown")); + + var id = await _repository.BeginAutomation(left.Id, 1, "curtain-close", "测试窗帘", _clock.Now.ToUnixTimeMilliseconds(), default); + Assert.NotNull(id); + await _repository.CompleteAutomation(id!, "readback-confirmed", _clock.Now.ToUnixTimeMilliseconds()); + Assert.Equal("readback-confirmed", Assert.Single((await _service.AutomationStatus(left.Id)).Records).Result); + + User(2); + await ExpectRejection(() => _service.AutomationStatus(left.Id)); + await ExpectRejection(() => _service.StartAutomation(left.Id)); + await ExpectRejection(() => _service.StopAutomation(left.Id)); + await ExpectRejection(() => _service.StartAutomation(left.Id, PreviewAction)); + await ExpectRejection(() => _service.StopAutomation(left.Id, PreviewAction)); + Assert.False((await _service.AutomationStatus(right.Id)).Available); + await ExpectRejection(() => _service.StartAutomation(right.Id)); + Assert.True(_automation.IsEnabled(left.Id, 1)); + + User(1); + await _service.StopAutomation(left.Id); + Assert.False((await _service.AutomationStatus(left.Id)).Enabled); + Assert.True(_automation.IsEnabled(left.Id, 1, PreviewAction)); + await _service.StartAutomation(left.Id); + Admin(); await _service.Release(left.Id); + Assert.False(_automation.IsEnabled(left.Id, 1)); + Assert.False(_automation.IsEnabled(left.Id, 1, PreviewAction)); + Assert.Null(await _repository.BeginAutomation(left.Id, 1, "curtain-close", "测试窗帘", _clock.Now.ToUnixTimeMilliseconds(), default)); + User(1); + state = await _service.AutomationStatus(left.Id); + Assert.False(state.Available); + Assert.Single(state.Records); + } + finally { await _automation.StopAsync(default); } + } + + [Fact] + public async Task Restarted_backend_services_leave_rules_stopped_and_return_the_same_saved_execution_history() + { + const string dehumidifier = "dehumidifier-humidity-start"; + var left = await Assign("left", 1); + User(1); + Ib43AutomationOutput before; + using (var automation = CreateAutomation(_repository, dehumidifier)) + { + await automation.StartAsync(default); + try + { + var service = CreateService(_repository, automation); + await service.StartAutomation(left.Id); + await service.StartAutomation(left.Id, dehumidifier); + foreach (var (action, target) in new[] { ("curtain-close", "测试窗帘"), (dehumidifier, "测试除湿机") }) + { + var now = _clock.GetUtcNow().ToUnixTimeMilliseconds(); + var id = await _repository.BeginAutomation(left.Id, 1, action, target, now, default); + Assert.NotNull(id); + await _repository.CompleteAutomation(id!, "readback-confirmed", now); + _clock.Now = _clock.Now.AddSeconds(1); + } + before = await service.AutomationStatus(left.Id); + Assert.Equal(2, before.Rules.Count); + Assert.All(before.Rules, rule => Assert.True(rule.Enabled)); + Assert.Equal(2, before.Records.Count); + } + finally { await automation.StopAsync(default); } + } + + _db.Ado.Close(); + using var reopened = new SqlSugarClient(new ConnectionConfig { ConnectionString = "DataSource=" + _databasePath, + DbType = DbType.Sqlite, IsAutoCloseConnection = true, InitKeyType = InitKeyType.Attribute }); + var repository = new Ib43DataRepository(reopened); + using var restarted = CreateAutomation(repository, dehumidifier); + await restarted.StartAsync(default); + try + { + var after = await CreateService(repository, restarted).AutomationStatus(left.Id); + Assert.False(after.Enabled); + Assert.Equal(before.Rules.Select(rule => rule with { Enabled = false }), after.Rules); + Assert.Equal(before.Records.ToArray(), after.Records.ToArray()); + } + finally { await restarted.StopAsync(default); } + } + + private sealed class TestAction(string key, string target) : IBedPresenceAction + { + public string Key => key; + public string Description => "测试"; + public bool SupportsTarget(string entityId) => entityId == target; + public Task ExecuteAsync(string entityId, CancellationToken token) => throw new InvalidOperationException("No real action in this test"); + } + + private sealed class TestExecutionLog(Ib43DataRepository repository) : IBedPresenceExecutionLog + { + public Task BeginAsync(string bindingId, long userId, string action, string target, DateTimeOffset triggeredAt, CancellationToken token) + => repository.BeginAutomation(bindingId, userId, action, target, triggeredAt.ToUnixTimeMilliseconds(), token); + public Task CompleteAsync(string id, string result, DateTimeOffset completedAt) + => repository.CompleteAutomation(id, result, completedAt.ToUnixTimeMilliseconds()); + } + [Fact] public async Task Local_maintenance_previews_then_binds_with_audit_and_is_idempotent() { @@ -201,6 +337,7 @@ public sealed class Ib43PersistenceTests : IDisposable public void Dispose() { + _automation.Dispose(); _db.Dispose(); Microsoft.Data.Sqlite.SqliteConnection.ClearAllPools(); File.Delete(_databasePath); diff --git a/Evergreen.Health.Application/Configuration/BedPresenceAutomation.json b/Evergreen.Health.Application/Configuration/BedPresenceAutomation.json index ad010b4..3dee3df 100644 --- a/Evergreen.Health.Application/Configuration/BedPresenceAutomation.json +++ b/Evergreen.Health.Application/Configuration/BedPresenceAutomation.json @@ -1,11 +1,77 @@ { "BedPresenceAutomation": { - "Mode": "Disabled", + "Mode": "Managed", "BindingId": "", "ProductKey": "evergreen-test", "SerialNumber": "IB43SIM001", "Side": "Left", "ActionKey": "curtain-close", - "TargetEntityId": "cover.czmydz_cn_2251706154_mym1_s_2_curtain" + "TargetEntityId": "cover.czmydz_cn_2251706154_mym1_s_2_curtain", + "TargetLabel": "美百帘窗帘", + "AdditionalRules": [ + { + "Mode": "Managed", + "ProductKey": "evergreen-test", + "SerialNumber": "IB43SIM001", + "Side": "Left", + "ActionKey": "dehumidifier-humidity-start", + "TargetEntityId": "humidifier.xiaomi_cn_972783525_60l", + "TargetLabel": "米家变频净化除湿机 Max" + }, + { + "Mode": "Managed", + "ProductKey": "evergreen-test", + "SerialNumber": "IB43SIM001", + "Side": "Left", + "ActionKey": "dehumidifier-humidity-preview", + "TargetEntityId": "humidifier.xiaomi_cn_972783525_60l", + "TargetLabel": "米家变频净化除湿机 Max" + }, + { + "Mode": "Managed", + "ProductKey": "evergreen-test", + "SerialNumber": "IB43SIM001", + "Side": "Left", + "ActionKey": "climate-temperature-preview", + "TargetEntityId": "climate.fawad_cn_974720212_3010", + "TargetLabel": "空调温控器2" + }, + { + "Mode": "Managed", + "ProductKey": "evergreen-test", + "SerialNumber": "IB43SIM001", + "Side": "Left", + "ActionKey": "climate-heat-start", + "TargetEntityId": "climate.fawad_cn_974720212_3010", + "TargetLabel": "空调温控器2" + }, + { + "Mode": "Managed", + "ProductKey": "evergreen-test", + "SerialNumber": "IB43SIM001", + "Side": "Left", + "ActionKey": "fragrance-quick-release", + "TargetEntityId": "button.xiaomi_cn_1206553658_xw002_fragrance_delivery_a_4_1", + "TargetLabel": "米家智能香氛机" + }, + { + "Mode": "Managed", + "ProductKey": "evergreen-test", + "SerialNumber": "IB43SIM001", + "Side": "Left", + "ActionKey": "wake-fragrance-quick-release", + "TargetEntityId": "button.xiaomi_cn_1238447032_xw002_fragrance_delivery_a_4_1", + "TargetLabel": "香氛机 唤醒" + }, + { + "Mode": "Managed", + "ProductKey": "evergreen-test", + "SerialNumber": "IB43SIM001", + "Side": "Left", + "ActionKey": "speaker-sleep-music", + "TargetEntityId": "media_player.xiaomi_cn_1221084723_l17a", + "TargetLabel": "Xiaomi Sound Pro" + } + ] } } diff --git a/Evergreen.Health.Application/Configuration/HomeAssistant.json b/Evergreen.Health.Application/Configuration/HomeAssistant.json index b830aad..af82ea2 100644 --- a/Evergreen.Health.Application/Configuration/HomeAssistant.json +++ b/Evergreen.Health.Application/Configuration/HomeAssistant.json @@ -15,6 +15,14 @@ "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", + "WakeFragranceSwitch": "switch.xiaomi_cn_1238447032_xw002_on_p_2_2", + "WakeFragranceLight": "light.xiaomi_cn_1238447032_xw002_s_3_ambient_light", + "WakeFragranceDuration": "number.xiaomi_cn_1238447032_xw002_fragrance_out_time_p_2_3", + "WakeFragranceInterval": "number.xiaomi_cn_1238447032_xw002_fragrance_out_time_p_2_4", + "WakeFragranceQuickRelease": "button.xiaomi_cn_1238447032_xw002_fragrance_delivery_a_4_1", + "WakeFragranceAutoRelease": "switch.xiaomi_cn_1238447032_xw002_auto_fragrance_p_4_1", + "WakeFragranceAutoLight": "switch.xiaomi_cn_1238447032_xw002_auto_light_p_4_2", + "WakeFragranceLightAutoOff": "switch.xiaomi_cn_1238447032_xw002_light_auto_off_p_4_3", "Speaker": "media_player.xiaomi_cn_1221084723_l17a", "SpeakerSleepMode": "switch.xiaomi_cn_1221084723_l17a_sleep_mode_p_7_3", "Climate": "climate.fawad_cn_974720212_3010", @@ -27,13 +35,6 @@ "Tvoc": "sensor.mirsz_cn_2027039858_et71_tvoc_density_p_3_8" }, "OnlyConfiguredDevices": true, - "DisplayOnlyDevices": [ - { - "Name": "香氛机 唤醒", - "EntityId": "switch.xiaomi_cn_1238447032_xw002_on_p_2_2", - "Category": "Fragrance" - } - ], "SleepScene": { "TargetHumidity": 55, "DehumidifierMode": "睡眠", diff --git a/Evergreen.Health.Application/Health/Devices/Audio/BedPresenceSleepMusicAction.cs b/Evergreen.Health.Application/Health/Devices/Audio/BedPresenceSleepMusicAction.cs new file mode 100644 index 0000000..c43ea8c --- /dev/null +++ b/Evergreen.Health.Application/Health/Devices/Audio/BedPresenceSleepMusicAction.cs @@ -0,0 +1,31 @@ +using Evergreen.Health.Application.Health.Integrations.HomeAssistant; +using Evergreen.Health.Application.Health.Integrations.MiAirX; +using Evergreen.Health.Application.Health.Scenes; +using Evergreen.Health.Application.Health.Sleep; +using Microsoft.Extensions.Options; + +namespace Evergreen.Health.Application.Health.Devices.Audio; + +public sealed class BedPresenceSleepMusicAction(IHomeAssistantGateway gateway, TcmMusicTransitionService music, + IOptions options) : IBedPresenceAction +{ + private const string Playlist = "balanced"; + + public string Key => "speaker-sleep-music"; + public string Description => "用 Xiaomi Sound Pro 播放舒睡音乐"; + public string ConditionLabel => "音箱空闲时播放默认舒睡曲目并渐弱音量。"; + + public bool SupportsTarget(string entityId) => options.Value.Enabled && + entityId?.StartsWith("media_player.", StringComparison.Ordinal) == true && entityId == options.Value.Entities.Speaker; + + public async Task ExecuteAsync(string entityId, CancellationToken token) + { + if (!SupportsTarget(entityId)) return "target-not-configured"; + if (!music.HasPlayableTracks(Playlist)) return "speaker-music-not-configured"; + var states = await gateway.GetStatesAsync(token); + var speaker = states.FirstOrDefault(state => string.Equals(state.EntityId, entityId, StringComparison.OrdinalIgnoreCase)); + if (speaker is null || !DeviceActionRunner.IsAvailable(speaker)) return "speaker-state-unavailable"; + if (speaker.State is not ("idle" or "off" or "standby")) return "speaker-not-idle"; + return await music.TryStartIdlePlaybackAsync(Playlist, token) ? "speaker-playback-sent" : "speaker-not-idle"; + } +} diff --git a/Evergreen.Health.Application/Health/Devices/Climate/BedPresenceClimateHeatAction.cs b/Evergreen.Health.Application/Health/Devices/Climate/BedPresenceClimateHeatAction.cs new file mode 100644 index 0000000..e94a48e --- /dev/null +++ b/Evergreen.Health.Application/Health/Devices/Climate/BedPresenceClimateHeatAction.cs @@ -0,0 +1,82 @@ +using Evergreen.Health.Application.Health.Devices.Contracts; +using Evergreen.Health.Application.Health.Devices.Environment; +using Evergreen.Health.Application.Health.Integrations.HomeAssistant; +using Evergreen.Health.Application.Health.Policies; +using Evergreen.Health.Application.Health.Scenes; +using Evergreen.Health.Application.Health.Sleep; +using Microsoft.Extensions.Options; + +namespace Evergreen.Health.Application.Health.Devices.Climate; + +public sealed class BedPresenceClimateHeatAction(ClimateModule climate, IndoorEnvironmentReader environment, + DeviceActionRunner runner, IOptions options) : IBedPresenceAction +{ + private const int TargetTemperature = 22; + + public string Key => "climate-heat-start"; + public string Description => "室温偏低时开启空调制热"; + public string ConditionLabel => $"室温低于 {TargetTemperature}℃ 且空调关机时,开启制热并设为 {TargetTemperature}℃。"; + public TimeSpan ExecutionTimeout => TimeSpan.FromSeconds(40); + + public bool SupportsTarget(string entityId) => options.Value.Enabled && + entityId?.StartsWith("climate.", StringComparison.Ordinal) == true && entityId == climate.PrimaryEntityId && + options.Value.Entities.Temperature?.StartsWith("sensor.", StringComparison.Ordinal) == true; + + public async Task ExecuteAsync(string entityId, CancellationToken token) + { + if (!SupportsTarget(entityId)) return "target-not-configured"; + var mode = climate.BuildParameterCommand("climate_mode", "heat"); + var temperature = climate.BuildParameterCommand("climate_temperature", TargetTemperature) with + { + ExpectedEntityState = "heat" + }; + + var reason = await runner.SendIfAsync(mode, states => GetStartSkipReason(states, mode, temperature), token); + if (reason is not null) return reason; + + try { await runner.VerifyParameterAsync(mode, token); } + catch (InvalidOperationException) { return "climate-heat-mode-unconfirmed"; } + + try + { + reason = await runner.SendIfAsync(temperature, states => + CurrentState(states, entityId)?.State == "heat" ? null : "climate-heat-state-changed", token); + if (reason is not null) return reason; + await runner.VerifyParameterAsync(temperature, token); + return "readback-confirmed"; + } + catch (Exception error) when (error is not OperationCanceledException) + { + return "climate-heat-target-unconfirmed"; + } + } + + private string? GetStartSkipReason(IReadOnlyList states, DeviceCommand mode, DeviceCommand temperature) + { + var sample = environment.Read(states); + if (sample.Celsius is not { } celsius) + return sample.Issue switch + { + IndoorTemperatureIssue.Stale => "temperature-stale", + IndoorTemperatureIssue.Invalid => "temperature-invalid", + _ => "temperature-unavailable" + }; + if (celsius >= TargetTemperature) return "temperature-not-low"; + + var state = CurrentState(states, mode.EntityId); + if (state is null || !DeviceActionRunner.IsAvailable(state)) return "climate-unavailable"; + if (state.State != "off") return "climate-not-off"; + + // 启动前同时校验制热模式和目标温度。 + try + { + mode.ValidateCurrentState!(state); + temperature.ValidateCurrentState!(state); + return null; + } + catch (InvalidOperationException) { return "climate-heat-unsupported"; } + } + + private static HomeAssistantState? CurrentState(IReadOnlyList states, string entityId) => + states.FirstOrDefault(state => string.Equals(state.EntityId, entityId, StringComparison.OrdinalIgnoreCase)); +} diff --git a/Evergreen.Health.Application/Health/Devices/Climate/BedPresenceClimatePreview.cs b/Evergreen.Health.Application/Health/Devices/Climate/BedPresenceClimatePreview.cs new file mode 100644 index 0000000..45f8c6b --- /dev/null +++ b/Evergreen.Health.Application/Health/Devices/Climate/BedPresenceClimatePreview.cs @@ -0,0 +1,47 @@ +using Evergreen.Health.Application.Health.Devices.Environment; +using Evergreen.Health.Application.Health.Integrations.HomeAssistant; +using Evergreen.Health.Application.Health.Policies; +using Evergreen.Health.Application.Health.Sleep; +using Microsoft.Extensions.Options; + +namespace Evergreen.Health.Application.Health.Devices.Climate; + +public sealed class BedPresenceClimatePreview(IHomeAssistantGateway gateway, IndoorEnvironmentReader environment, + IOptions options) : IBedPresenceAction +{ + private HomeAssistantOptions Configuration => options.Value; + + public string Key => "climate-temperature-preview"; + public string Description => "按室温判断空调模式"; + public bool PreviewOnly => true; + public string ConditionLabel => $"室温高于 {RecommendedRoomRange.Default.Maximum}℃ 时计划制冷,低于 {RecommendedRoomRange.Default.Minimum}℃ 时计划制热。"; + + public bool SupportsTarget(string entityId) => Configuration.Enabled && + entityId?.StartsWith("climate.", StringComparison.Ordinal) == true && entityId == Configuration.Entities.Climate && + Configuration.Entities.Temperature?.StartsWith("sensor.", StringComparison.Ordinal) == true; + + public async Task ExecuteAsync(string entityId, CancellationToken token) + { + if (!SupportsTarget(entityId)) return "target-not-configured"; + IReadOnlyList states; + try { states = await gateway.GetStatesAsync(token); } + catch (Exception error) when (error is not OperationCanceledException) + { + return "preview-temperature-unavailable"; + } + + var sample = environment.Read(states); + return RoomTemperaturePolicy.Evaluate(sample, RecommendedRoomRange.Default).Mode switch + { + "cool" => "preview-climate-cool", + "heat" => "preview-climate-heat", + "off" => "preview-temperature-within-range", + _ => sample.Issue switch + { + IndoorTemperatureIssue.Stale => "preview-temperature-stale", + IndoorTemperatureIssue.Invalid => "preview-temperature-invalid", + _ => "preview-temperature-unavailable" + } + }; + } +} diff --git a/Evergreen.Health.Application/Health/Devices/Contracts/DeviceCommand.cs b/Evergreen.Health.Application/Health/Devices/Contracts/DeviceCommand.cs index 54be018..3fbd55f 100644 --- a/Evergreen.Health.Application/Health/Devices/Contracts/DeviceCommand.cs +++ b/Evergreen.Health.Application/Health/Devices/Contracts/DeviceCommand.cs @@ -13,6 +13,7 @@ public sealed record DeviceCommand(string Name, string EntityId, string Domain, public Action? ValidateCurrentState { get; init; } // 不为空时 ExpectedState 表示此属性的期望值,而不是实体的顶层状态。 public string? ExpectedAttribute { get; init; } + public string? ExpectedEntityState { get; init; } public bool RetryOnVerificationMismatch { get; init; } = true; public Dictionary ToServiceData() { diff --git a/Evergreen.Health.Application/Health/Devices/Dehumidifier/BedPresenceDehumidifierPreview.cs b/Evergreen.Health.Application/Health/Devices/Dehumidifier/BedPresenceDehumidifierPreview.cs new file mode 100644 index 0000000..2f39822 --- /dev/null +++ b/Evergreen.Health.Application/Health/Devices/Dehumidifier/BedPresenceDehumidifierPreview.cs @@ -0,0 +1,27 @@ +using Evergreen.Health.Application.Health.Integrations.HomeAssistant; +using Evergreen.Health.Application.Health.Sleep; +using Microsoft.Extensions.Options; + +namespace Evergreen.Health.Application.Health.Devices.Dehumidifier; + +public sealed class BedPresenceDehumidifierPreview(IHomeAssistantGateway gateway, IOptions options, + TimeProvider clock) : IBedPresenceAction +{ + private HomeAssistantOptions Configuration => options.Value; + private int Threshold => Configuration.SleepScene.TargetHumidity; + + public string Key => "dehumidifier-humidity-preview"; + public string Description => "湿度偏高时开启除湿机"; + public bool PreviewOnly => true; + public string ConditionLabel => $"室内相对湿度超过 {Threshold}% 时记录开机计划。"; + + public bool SupportsTarget(string entityId) => DehumidifierHumidityCondition.SupportsTarget(Configuration, entityId); + + public async Task ExecuteAsync(string entityId, CancellationToken token) + { + if (!SupportsTarget(entityId)) return "target-not-configured"; + var states = await gateway.GetStatesAsync(token); + var reason = DehumidifierHumidityCondition.SkipReason(states, Configuration, clock.GetUtcNow()); + return reason is null ? "preview-would-start" : $"preview-{reason}"; + } +} diff --git a/Evergreen.Health.Application/Health/Devices/Dehumidifier/BedPresenceDehumidifierStartAction.cs b/Evergreen.Health.Application/Health/Devices/Dehumidifier/BedPresenceDehumidifierStartAction.cs new file mode 100644 index 0000000..2704788 --- /dev/null +++ b/Evergreen.Health.Application/Health/Devices/Dehumidifier/BedPresenceDehumidifierStartAction.cs @@ -0,0 +1,56 @@ +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.Dehumidifier; + +public sealed class BedPresenceDehumidifierStartAction(DehumidifierModule dehumidifier, DeviceActionRunner runner, + IOptions options, TimeProvider clock) : IBedPresenceAction +{ + private HomeAssistantOptions Configuration => options.Value; + + public string Key => "dehumidifier-humidity-start"; + public string Description => "湿度偏高时开启除湿机"; + public string ConditionLabel => $"室内相对湿度超过 {Configuration.SleepScene.TargetHumidity}% 且除湿机关机时,发送开机指令。"; + + public bool SupportsTarget(string entityId) => entityId == dehumidifier.PrimaryEntityId && + DehumidifierHumidityCondition.SupportsTarget(Configuration, entityId); + + public async Task ExecuteAsync(string entityId, CancellationToken token) + { + if (!SupportsTarget(entityId)) return "target-not-configured"; + var command = dehumidifier.BuildStartCommand() with + { + ExpectedState = "on", + RetryOnVerificationMismatch = false + }; + var reason = await runner.SendIfAsync(command, states => GetSkipReason(states, entityId), token); + if (reason is not null) return reason; + + try + { + await runner.VerifyParameterAsync(command, token); + return "readback-confirmed"; + } + catch (InvalidOperationException) + { + return "sent-readback-unconfirmed"; + } + } + + private string? GetSkipReason(IReadOnlyList states, string entityId) + { + var reason = DehumidifierHumidityCondition.SkipReason(states, Configuration, clock.GetUtcNow()); + if (reason is not null) return reason; + + var state = states.FirstOrDefault(item => string.Equals(item.EntityId, entityId, StringComparison.OrdinalIgnoreCase)); + if (state is null || !DeviceActionRunner.IsAvailable(state)) return "dehumidifier-unavailable"; + return state.State switch + { + "off" => null, + "on" => "dehumidifier-already-on", + _ => "dehumidifier-state-not-off" + }; + } +} diff --git a/Evergreen.Health.Application/Health/Devices/Dehumidifier/DehumidifierHumidityCondition.cs b/Evergreen.Health.Application/Health/Devices/Dehumidifier/DehumidifierHumidityCondition.cs new file mode 100644 index 0000000..f3c6d73 --- /dev/null +++ b/Evergreen.Health.Application/Health/Devices/Dehumidifier/DehumidifierHumidityCondition.cs @@ -0,0 +1,34 @@ +using Evergreen.Health.Application.Health.Integrations.HomeAssistant; +using Evergreen.Health.Application.Health.Scenes; +using System.Globalization; + +namespace Evergreen.Health.Application.Health.Devices.Dehumidifier; + +internal static class DehumidifierHumidityCondition +{ + private static readonly TimeSpan MaxHumidityAge = TimeSpan.FromMinutes(30); + + public static bool SupportsTarget(HomeAssistantOptions configuration, string entityId) => + configuration.Enabled && configuration.SleepScene.TargetHumidity is >= 40 and <= 70 && + entityId?.StartsWith("humidifier.", StringComparison.Ordinal) == true && + entityId == configuration.Entities.Dehumidifier && + configuration.Entities.Humidity?.StartsWith("sensor.", StringComparison.Ordinal) == true; + + public static string? SkipReason(IReadOnlyList states, HomeAssistantOptions configuration, DateTimeOffset now) + { + var humidity = states.FirstOrDefault(state => + string.Equals(state.EntityId, configuration.Entities.Humidity, StringComparison.OrdinalIgnoreCase)); + if (humidity is null || !DeviceActionRunner.IsAvailable(humidity)) return "humidity-unavailable"; + + var observedAt = humidity.LastReported ?? humidity.LastUpdated; + if (observedAt is null || now - observedAt > MaxHumidityAge || observedAt > now.AddMinutes(1)) + return "humidity-stale"; + + if (humidity.GetStringAttribute("unit_of_measurement") != "%" || + !double.TryParse(humidity.State, NumberStyles.Float, CultureInfo.InvariantCulture, out var percent) || + !double.IsFinite(percent) || percent is < 0 or > 100) + return "humidity-invalid"; + + return percent > configuration.SleepScene.TargetHumidity ? null : "humidity-normal"; + } +} diff --git a/Evergreen.Health.Application/Health/Devices/Dehumidifier/DehumidifierModule.cs b/Evergreen.Health.Application/Health/Devices/Dehumidifier/DehumidifierModule.cs index 7e23855..a4ac32e 100644 --- a/Evergreen.Health.Application/Health/Devices/Dehumidifier/DehumidifierModule.cs +++ b/Evergreen.Health.Application/Health/Devices/Dehumidifier/DehumidifierModule.cs @@ -31,10 +31,12 @@ public sealed class DehumidifierModule : HealthDeviceModule public override string PrimaryEntityId => _entities.Dehumidifier; public override IReadOnlyList Parameters { get; } + internal DeviceCommand BuildStartCommand() => new("开启除湿机", PrimaryEntityId, "humidifier", "turn_on"); + public override async Task ExecuteSleepAsync(DeviceSceneContext context, PersonalizedSceneConfig config, string constitutionCode, CancellationToken cancellationToken) { - await context.RunAsync(new("开启除湿机", PrimaryEntityId, "humidifier", "turn_on"), cancellationToken); + await context.RunAsync(BuildStartCommand(), cancellationToken); await context.RunAsync(BuildParameterCommand("humidity", config.TargetHumidity), cancellationToken); await context.RunAsync(BuildParameterCommand("dehumidifier_mode", config.DehumidifierMode), cancellationToken); await context.RunAsync(BuildParameterCommand("fan_level", config.FanLevel), cancellationToken); diff --git a/Evergreen.Health.Application/Health/Devices/Environment/IndoorEnvironmentReader.cs b/Evergreen.Health.Application/Health/Devices/Environment/IndoorEnvironmentReader.cs index b4d175f..190bdac 100644 --- a/Evergreen.Health.Application/Health/Devices/Environment/IndoorEnvironmentReader.cs +++ b/Evergreen.Health.Application/Health/Devices/Environment/IndoorEnvironmentReader.cs @@ -13,22 +13,30 @@ public sealed class IndoorEnvironmentReader public IndoorEnvironmentReader(IOptions options, TimeProvider time) => (_entities, _time) = (options.Value.Entities, time); - public IndoorTemperatureSample Read(IReadOnlyDictionary states) + public IndoorTemperatureSample Read(IReadOnlyDictionary states) => + ReadState(string.IsNullOrWhiteSpace(_entities.Temperature) ? null : states.GetValueOrDefault(_entities.Temperature)); + + public IndoorTemperatureSample Read(IReadOnlyList states) => + ReadState(states.LastOrDefault(state => + string.Equals(state.EntityId, _entities.Temperature, StringComparison.OrdinalIgnoreCase))); + + private IndoorTemperatureSample ReadState(HomeAssistantState? state) { - if (string.IsNullOrWhiteSpace(_entities.Temperature) || - !states.TryGetValue(_entities.Temperature, out var state) || !DeviceActionRunner.IsAvailable(state)) - return new(null, null, false, "室内温度实体未配置或不可用"); + if (state is null || !DeviceActionRunner.IsAvailable(state)) + return new(null, null, false, "室内温度实体未配置或不可用") { Issue = IndoorTemperatureIssue.Unavailable }; var observedAt = state.LastReported ?? state.LastUpdated; - if (observedAt is null || _time.GetUtcNow() - observedAt > TimeSpan.FromMinutes(30) || - observedAt > _time.GetUtcNow().AddMinutes(1)) - return new(null, observedAt, false, "室内温度数据缺少有效时间或已过期"); + var now = _time.GetUtcNow(); + if (observedAt is null || now - observedAt > TimeSpan.FromMinutes(30) || observedAt > now.AddMinutes(1)) + return new(null, observedAt, false, "室内温度数据缺少有效时间或已过期") { Issue = IndoorTemperatureIssue.Stale }; if (!double.TryParse(state.State, NumberStyles.Float, CultureInfo.InvariantCulture, out var celsius) || - !double.IsFinite(celsius)) return new(null, observedAt, false, "室内温度数据无效"); + !double.IsFinite(celsius)) + return new(null, observedAt, false, "室内温度数据无效") { Issue = IndoorTemperatureIssue.Invalid }; var unit = state.GetStringAttribute("unit_of_measurement"); if (unit is "°F" or "F") celsius = (celsius - 32) * 5 / 9; else if (!string.IsNullOrWhiteSpace(unit) && unit is not ("°C" or "C" or "℃")) - return new(null, observedAt, false, "室内温度单位未识别"); - if (celsius is < -20 or > 60) return new(null, observedAt, false, "室内温度超出有效范围"); + return new(null, observedAt, false, "室内温度单位未识别") { Issue = IndoorTemperatureIssue.Invalid }; + if (celsius is < -20 or > 60) + return new(null, observedAt, false, "室内温度超出有效范围") { Issue = IndoorTemperatureIssue.Invalid }; return new(celsius, observedAt, true, "室内温度数据有效"); } } diff --git a/Evergreen.Health.Application/Health/Devices/Fragrance/BedPresenceFragranceReleaseAction.cs b/Evergreen.Health.Application/Health/Devices/Fragrance/BedPresenceFragranceReleaseAction.cs new file mode 100644 index 0000000..a068fa6 --- /dev/null +++ b/Evergreen.Health.Application/Health/Devices/Fragrance/BedPresenceFragranceReleaseAction.cs @@ -0,0 +1,27 @@ +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.Fragrance; + +public sealed class BedPresenceFragranceReleaseAction(DeviceActionRunner runner, IOptions options, + bool wakeDevice) : IBedPresenceAction +{ + private string ConfiguredButton => wakeDevice ? options.Value.Entities.WakeFragranceQuickRelease : options.Value.Entities.FragranceQuickRelease; + + public string Key => wakeDevice ? "wake-fragrance-quick-release" : "fragrance-quick-release"; + public string Description => wakeDevice ? "让香氛机「唤醒」出香一次" : "让米家智能香氛机出香一次"; + + public bool SupportsTarget(string entityId) => options.Value.Enabled && + entityId?.StartsWith("button.", StringComparison.Ordinal) == true && entityId == ConfiguredButton; + + public async Task ExecuteAsync(string entityId, CancellationToken token) + { + if (!SupportsTarget(entityId)) return "target-not-configured"; + var reason = await runner.SendIfAsync(FragranceModule.BuildQuickRelease(entityId), states => + states.Any(state => string.Equals(state.EntityId, entityId, StringComparison.OrdinalIgnoreCase) && + DeviceActionRunner.IsAvailable(state)) ? null : "fragrance-release-unavailable", token); + return reason ?? "fragrance-release-sent"; + } +} diff --git a/Evergreen.Health.Application/Health/Devices/Fragrance/FragranceModule.cs b/Evergreen.Health.Application/Health/Devices/Fragrance/FragranceModule.cs index 9ced09b..bf93f71 100644 --- a/Evergreen.Health.Application/Health/Devices/Fragrance/FragranceModule.cs +++ b/Evergreen.Health.Application/Health/Devices/Fragrance/FragranceModule.cs @@ -9,36 +9,52 @@ namespace Evergreen.Health.Application.Health.Devices.Fragrance; public sealed class FragranceModule : HealthDeviceModule { private readonly HomeAssistantEntityOptions _entities; - public FragranceModule(DeviceActionRunner runner, IOptions options) : base(runner) + private readonly bool _wakeDevice; + + public FragranceModule(DeviceActionRunner runner, IOptions options) + : this(runner, options, wakeDevice: false) { } + + internal FragranceModule(DeviceActionRunner runner, IOptions options, bool wakeDevice) : base(runner) { _entities = options.Value.Entities; - Parameters = + _wakeDevice = wakeDevice; + var duration = wakeDevice ? _entities.WakeFragranceDuration : _entities.FragranceDuration; + var intervalEntity = wakeDevice ? _entities.WakeFragranceInterval : _entities.FragranceInterval; + var light = wakeDevice ? _entities.WakeFragranceLight : _entities.FragranceLight; + var quickRelease = wakeDevice ? _entities.WakeFragranceQuickRelease : _entities.FragranceQuickRelease; + var autoRelease = wakeDevice ? _entities.WakeFragranceAutoRelease : _entities.FragranceAutoRelease; + var autoLight = wakeDevice ? _entities.WakeFragranceAutoLight : _entities.FragranceAutoLight; + var lightAutoOff = wakeDevice ? _entities.WakeFragranceLightAutoOff : _entities.FragranceLightAutoOff; + DeviceParameterDefinition[] parameters = [ - DeviceValues.Number("fragrance_duration", _entities.FragranceDuration, 2, 6, "出香时长"), - new("fragrance_interval", _entities.FragranceInterval, value => + DeviceValues.Number("fragrance_duration", duration, 2, 6, "出香时长"), + new("fragrance_interval", intervalEntity, value => { var interval = DeviceValues.Integer(value, 10, 20, "出香间隔"); if (interval % 5 != 0) throw new InvalidOperationException("出香间隔仅支持 10、15 或 20 分钟"); - return new("设置出香间隔", _entities.FragranceInterval, "number", "set_value", + return new("设置出香间隔", intervalEntity, "number", "set_value", new Dictionary { ["value"] = interval }); }, "出香间隔", 10, 20), - DeviceValues.Switch("fragrance_light", _entities.FragranceLight, "light", "香氛灯"), - new("fragrance_quick_release", _entities.FragranceQuickRelease, _ => new("快速出香", - _entities.FragranceQuickRelease, "button", "press"), "快速出香"), - DeviceValues.Switch("fragrance_auto_release", _entities.FragranceAutoRelease, "switch", "人来增香"), - DeviceValues.Switch("fragrance_auto_light", _entities.FragranceAutoLight, "switch", "感应夜灯"), - DeviceValues.Switch("fragrance_light_auto_off", _entities.FragranceLightAutoOff, "switch", "夜灯自动关闭") + DeviceValues.Switch("fragrance_light", light, "light", "香氛灯"), + new("fragrance_quick_release", quickRelease, _ => BuildQuickRelease(quickRelease), "快速出香"), + DeviceValues.Switch("fragrance_auto_release", autoRelease, "switch", "人来增香"), + DeviceValues.Switch("fragrance_auto_light", autoLight, "switch", "感应夜灯"), + DeviceValues.Switch("fragrance_light_auto_off", lightAutoOff, "switch", "夜灯自动关闭") ]; + Parameters = wakeDevice ? parameters.Select(parameter => parameter with { Key = $"wake_{parameter.Key}" }).ToArray() : parameters; } - public override string Key => "fragrance"; + public override string Key => _wakeDevice ? "wake-fragrance" : "fragrance"; public override string Category => nameof(HealthDeviceCategory.Fragrance); - public override string DisplayName => "智能香氛机"; - public override string PrimaryEntityId => _entities.FragranceSwitch; + public override string DisplayName => _wakeDevice ? "香氛机 唤醒" : "智能香氛机"; + public override string PrimaryEntityId => _wakeDevice ? _entities.WakeFragranceSwitch : _entities.FragranceSwitch; public override IReadOnlyList Parameters { get; } + internal static DeviceCommand BuildQuickRelease(string entityId) => new("快速出香", entityId, "button", "press"); + public override async Task ExecuteSleepAsync(DeviceSceneContext context, PersonalizedSceneConfig config, string constitutionCode, CancellationToken cancellationToken) { + if (_wakeDevice) return; await context.RunAsync(new(config.EnableFragrance ? "开启香氛机" : "关闭香氛机", PrimaryEntityId, "switch", config.EnableFragrance ? "turn_on" : "turn_off"), cancellationToken); if (config.EnableFragrance) @@ -53,7 +69,7 @@ public sealed class FragranceModule : HealthDeviceModule cancellationToken); } - public override IEnumerable GetShutdownCommands() => + public override IEnumerable GetShutdownCommands() => _wakeDevice ? [] : [ new("关闭香氛机", PrimaryEntityId, "switch", "turn_off", ExpectedState: "off"), new("关闭香氛灯", _entities.FragranceLight, "light", "turn_off", ExpectedState: "off"), @@ -62,7 +78,7 @@ public sealed class FragranceModule : HealthDeviceModule ]; public override HomeAssistantScenePresetItem? BuildPreview(PersonalizedSceneConfig config, string constitutionCode) - => string.IsNullOrWhiteSpace(PrimaryEntityId) ? null : Preview(config.EnableFragrance ? "开启香氛释放" : "关闭香氛释放", + => _wakeDevice || string.IsNullOrWhiteSpace(PrimaryEntityId) ? null : Preview(config.EnableFragrance ? "开启香氛释放" : "关闭香氛释放", config.EnableFragrance ? "开启香氛机;香氛灯可用时同步开启柔光" : "当前体质预设不启动香氛", new() { ["power"] = config.EnableFragrance ? "开启" : "关闭" }); } diff --git a/Evergreen.Health.Application/Health/Devices/HealthDeviceRegistration.cs b/Evergreen.Health.Application/Health/Devices/HealthDeviceRegistration.cs index 6d0e2f7..e27a20c 100644 --- a/Evergreen.Health.Application/Health/Devices/HealthDeviceRegistration.cs +++ b/Evergreen.Health.Application/Health/Devices/HealthDeviceRegistration.cs @@ -34,6 +34,16 @@ public static class HealthDeviceRegistration services.AddOptions(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(provider => new BedPresenceFragranceReleaseAction( + provider.GetRequiredService(), provider.GetRequiredService>(), wakeDevice: false)); + services.AddSingleton(provider => new BedPresenceFragranceReleaseAction( + provider.GetRequiredService(), provider.GetRequiredService>(), wakeDevice: true)); + services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); services.AddSingleton(provider => provider.GetRequiredService()); services.AddHostedService(provider => provider.GetRequiredService()); @@ -74,6 +84,8 @@ public static class HealthDeviceRegistration services.AddSingleton(provider => provider.GetRequiredService()); services.AddSingleton(); services.AddSingleton(provider => provider.GetRequiredService()); + services.AddSingleton(provider => new FragranceModule( + provider.GetRequiredService(), provider.GetRequiredService>(), wakeDevice: true)); services.AddSingleton(provider => new( provider.GetRequiredService(), provider.GetRequiredService>(), provider.GetRequiredService().ForEntity( diff --git a/Evergreen.Health.Application/Health/Entities/HealthIb43AutomationExecution.cs b/Evergreen.Health.Application/Health/Entities/HealthIb43AutomationExecution.cs new file mode 100644 index 0000000..42ce3db --- /dev/null +++ b/Evergreen.Health.Application/Health/Entities/HealthIb43AutomationExecution.cs @@ -0,0 +1,24 @@ +using SqlSugar; + +namespace Evergreen.Health.Application.Health.Entities; + +[SugarTable("health_ib43_automation_execution", "IB43床位家具联动执行记录")] +[SugarIndex("ix_ib43_automation_owner", nameof(UserId), OrderByType.Asc, nameof(BindingId), OrderByType.Asc, + nameof(TriggeredAtMs), OrderByType.Desc)] +public sealed class HealthIb43AutomationExecution +{ + [SugarColumn(IsPrimaryKey = true, Length = 32)] + public string Id { get; set; } = Guid.NewGuid().ToString("N"); + [SugarColumn(Length = 32)] + public string BindingId { get; set; } = ""; + public long UserId { get; set; } + [SugarColumn(Length = 64)] + public string ActionKey { get; set; } = ""; + [SugarColumn(Length = 128)] + public string TargetLabel { get; set; } = ""; + public long TriggeredAtMs { get; set; } + [SugarColumn(IsNullable = true)] + public long? CompletedAtMs { get; set; } + [SugarColumn(Length = 48)] + public string Result { get; set; } = "pending"; +} diff --git a/Evergreen.Health.Application/Health/Integrations/HomeAssistant/HomeAssistantOptions.cs b/Evergreen.Health.Application/Health/Integrations/HomeAssistant/HomeAssistantOptions.cs index 6ccbcd8..dbea880 100644 --- a/Evergreen.Health.Application/Health/Integrations/HomeAssistant/HomeAssistantOptions.cs +++ b/Evergreen.Health.Application/Health/Integrations/HomeAssistant/HomeAssistantOptions.cs @@ -46,6 +46,14 @@ public sealed class HomeAssistantEntityOptions public string FragranceDuration { get; set; } = string.Empty; public string FragranceInterval { get; set; } = string.Empty; public string FragranceQuickRelease { get; set; } = string.Empty; + public string WakeFragranceSwitch { get; set; } = string.Empty; + public string WakeFragranceLight { get; set; } = string.Empty; + public string WakeFragranceDuration { get; set; } = string.Empty; + public string WakeFragranceInterval { get; set; } = string.Empty; + public string WakeFragranceQuickRelease { get; set; } = string.Empty; + public string WakeFragranceAutoRelease { get; set; } = string.Empty; + public string WakeFragranceAutoLight { get; set; } = string.Empty; + public string WakeFragranceLightAutoOff { get; set; } = string.Empty; public string FragranceAutoRelease { get; set; } = string.Empty; public string FragranceAutoLight { get; set; } = string.Empty; public string FragranceLightAutoOff { get; set; } = string.Empty; diff --git a/Evergreen.Health.Application/Health/Integrations/Ib43/Ib43AutomationExecutionLog.cs b/Evergreen.Health.Application/Health/Integrations/Ib43/Ib43AutomationExecutionLog.cs new file mode 100644 index 0000000..34b1a63 --- /dev/null +++ b/Evergreen.Health.Application/Health/Integrations/Ib43/Ib43AutomationExecutionLog.cs @@ -0,0 +1,21 @@ +using Evergreen.Health.Application.Health.Sleep; + +namespace Evergreen.Health.Application.Health.Integrations.Ib43; + +public sealed class Ib43AutomationExecutionLog(IServiceScopeFactory scopes) : IBedPresenceExecutionLog +{ + public async Task BeginAsync(string bindingId, long userId, string action, string target, + DateTimeOffset triggeredAt, CancellationToken token) + { + using var scope = scopes.CreateScope(); + return await scope.ServiceProvider.GetRequiredService() + .BeginAutomation(bindingId, userId, action, target, triggeredAt.ToUnixTimeMilliseconds(), token); + } + + public async Task CompleteAsync(string id, string result, DateTimeOffset completedAt) + { + using var scope = scopes.CreateScope(); + await scope.ServiceProvider.GetRequiredService() + .CompleteAutomation(id, result, completedAt.ToUnixTimeMilliseconds()); + } +} diff --git a/Evergreen.Health.Application/Health/Integrations/Ib43/Ib43DataRepository.cs b/Evergreen.Health.Application/Health/Integrations/Ib43/Ib43DataRepository.cs index 58b1aae..e9436a6 100644 --- a/Evergreen.Health.Application/Health/Integrations/Ib43/Ib43DataRepository.cs +++ b/Evergreen.Health.Application/Health/Integrations/Ib43/Ib43DataRepository.cs @@ -31,6 +31,26 @@ public sealed class Ib43DataRepository(ISqlSugarClient db) await db.Queryable().Where(b => b.Id == id && b.UserId == userId).FirstAsync() ?? throw new AppFriendlyException("床位绑定不存在或无权访问", null) { StatusCode = 404 }; + public Task> AutomationHistory(long userId, string bindingId) => + db.Queryable().Where(row => row.UserId == userId && row.BindingId == bindingId) + .OrderBy(row => row.TriggeredAtMs, OrderByType.Desc).Take(20).ToListAsync(); + + public async Task BeginAutomation(string bindingId, long userId, string action, string target, long triggeredAtMs, CancellationToken token) + { + token.ThrowIfCancellationRequested(); + if (!await UserEnabled(userId) || !await db.Queryable().AnyAsync(b => b.Id == bindingId && + b.UserId == userId && b.Simulation && b.EndedAtMs == null && b.StartedAtMs <= triggeredAtMs)) return null; + var record = new HealthIb43AutomationExecution + { BindingId = bindingId, UserId = userId, ActionKey = action, TargetLabel = target, TriggeredAtMs = triggeredAtMs }; + token.ThrowIfCancellationRequested(); + await db.Insertable(record).ExecuteCommandAsync(); + return record.Id; + } + + public Task CompleteAutomation(string id, string result, long completedAtMs) => db.Updateable() + .SetColumns(row => new HealthIb43AutomationExecution { Result = result, CompletedAtMs = completedAtMs }) + .Where(row => row.Id == id && row.CompletedAtMs == null).ExecuteCommandAsync(); + public async Task Assign(HealthIb43Binding binding) { if (await db.Queryable().AnyAsync(b => b.ActiveSlot == binding.ActiveSlot)) diff --git a/Evergreen.Health.Application/Health/Integrations/MiAirX/TcmMusicTransitionService.cs b/Evergreen.Health.Application/Health/Integrations/MiAirX/TcmMusicTransitionService.cs index 82d8689..7ae4514 100644 --- a/Evergreen.Health.Application/Health/Integrations/MiAirX/TcmMusicTransitionService.cs +++ b/Evergreen.Health.Application/Health/Integrations/MiAirX/TcmMusicTransitionService.cs @@ -61,7 +61,13 @@ public sealed class TcmMusicTransitionService : IHostedService, ITcmMusicRuntime tracks is not null && tracks.Count > 0 && tracks.All(track => IsValidTrack(track)); - public async Task StartForConstitutionAsync(string constitutionCode, CancellationToken cancellationToken) + public Task StartForConstitutionAsync(string constitutionCode, CancellationToken cancellationToken) => + StartAsync(constitutionCode, onlyIfIdle: false, cancellationToken); + + internal Task TryStartIdlePlaybackAsync(string constitutionCode, CancellationToken cancellationToken) => + StartAsync(constitutionCode, onlyIfIdle: true, cancellationToken); + + private async Task StartAsync(string constitutionCode, bool onlyIfIdle, CancellationToken cancellationToken) { if (!HasPlayableTracks(constitutionCode)) throw new InvalidOperationException($"{constitutionCode} 的体质音乐 OSS URL 尚未配置完整"); @@ -69,12 +75,14 @@ public sealed class TcmMusicTransitionService : IHostedService, ITcmMusicRuntime await _operationLock.WaitAsync(cancellationToken); try { + if (onlyIfIdle && _activeTask is { IsCompleted: false }) return false; await CancelCurrentLoopAsync(); await _miAirX.SetVolumeAsync(DemoStartVolume, cancellationToken); await _miAirX.PlayUrlAsync(tracks[0].Url, cancellationToken); UpdateStatus(true, DemoStartVolume, "体质音乐正在自动渐弱"); _activeCancellation = new CancellationTokenSource(); _activeTask = RunFadeAsync(tracks, constitutionCode, _activeCancellation.Token); + return true; } finally { _operationLock.Release(); } } diff --git a/Evergreen.Health.Application/Health/Policies/RoomTemperaturePolicy.cs b/Evergreen.Health.Application/Health/Policies/RoomTemperaturePolicy.cs index 3d604d4..ad4ebc9 100644 --- a/Evergreen.Health.Application/Health/Policies/RoomTemperaturePolicy.cs +++ b/Evergreen.Health.Application/Health/Policies/RoomTemperaturePolicy.cs @@ -4,15 +4,21 @@ namespace Evergreen.Health.Application.Health.Policies; public sealed record RecommendedRoomRange(int Minimum, int Maximum) { + public static RecommendedRoomRange Default { get; } = new(22, 25); public string Display => $"{Minimum}–{Maximum}℃"; public static RecommendedRoomRange ForConstitution(string code) => code switch { "yang_deficient" => new(24, 26), "yin_deficient" or "damp_heat" => new(21, 24), - "phlegm_damp" => new(22, 24), "blood_stasis" => new(23, 25), _ => new(22, 25) + "phlegm_damp" => new(22, 24), "blood_stasis" => new(23, 25), _ => Default }; } -public sealed record IndoorTemperatureSample(double? Celsius, DateTimeOffset? ObservedAt, bool Available, string Reason); +public enum IndoorTemperatureIssue { Unavailable, Stale, Invalid } + +public sealed record IndoorTemperatureSample(double? Celsius, DateTimeOffset? ObservedAt, bool Available, string Reason) +{ + public IndoorTemperatureIssue? Issue { get; init; } +} public static class RoomTemperaturePolicy { diff --git a/Evergreen.Health.Application/Health/Scenes/DeviceActionRunner.cs b/Evergreen.Health.Application/Health/Scenes/DeviceActionRunner.cs index 0ad0e73..011d9fe 100644 --- a/Evergreen.Health.Application/Health/Scenes/DeviceActionRunner.cs +++ b/Evergreen.Health.Application/Health/Scenes/DeviceActionRunner.cs @@ -25,13 +25,22 @@ public sealed class DeviceActionRunner public async Task ReadAvailableStateAsync(string entityId, CancellationToken cancellationToken) { if (string.IsNullOrWhiteSpace(entityId)) throw new InvalidOperationException("参数实体尚未配置"); - var state = (await _gateway.GetStatesAsync(cancellationToken)).FirstOrDefault(item => + return RequireAvailableState(await _gateway.GetStatesAsync(cancellationToken), entityId); + } + + private static HomeAssistantState RequireAvailableState(IReadOnlyList states, string entityId) + { + var state = states.FirstOrDefault(item => string.Equals(item.EntityId, entityId, StringComparison.OrdinalIgnoreCase)); if (state is null || !IsAvailable(state)) throw new InvalidOperationException("目标参数实体当前离线或不可用"); return state; } - public async Task SendAsync(DeviceCommand command, CancellationToken cancellationToken) + public Task SendAsync(DeviceCommand command, CancellationToken cancellationToken) => + SendIfAsync(command, null, cancellationToken); + + public async Task SendIfAsync(DeviceCommand command, + Func, string?>? getSkipReason, CancellationToken cancellationToken) { cancellationToken.ThrowIfCancellationRequested(); if (string.IsNullOrWhiteSpace(command.EntityId)) @@ -43,9 +52,18 @@ public sealed class DeviceActionRunner using var bounded = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, timeout.Token); try { + IReadOnlyList? states = null; + if (getSkipReason is not null) + { + states = await _gateway.GetStatesAsync(bounded.Token); + var reason = getSkipReason(states); + if (reason is not null) return reason; + } if (command.ValidateCurrentState is not null) - command.ValidateCurrentState(await ReadAvailableStateAsync(command.EntityId, bounded.Token)); + command.ValidateCurrentState(states is null ? await ReadAvailableStateAsync(command.EntityId, bounded.Token) + : RequireAvailableState(states, command.EntityId)); foreach (var rule in _safety) await rule.ValidateAsync(command, bounded.Token); + bounded.Token.ThrowIfCancellationRequested(); await _gateway.CallServiceAsync(command.Domain, command.Service, command.ToServiceData(), bounded.Token); } catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested && timeout.IsCancellationRequested) @@ -53,6 +71,7 @@ public sealed class DeviceActionRunner throw new TimeoutException($"{command.Name}发送超时,未确认完成"); } foreach (var rule in _safety) rule.Accepted(command, _time.GetUtcNow()); + return null; } finally { _commandGate.Release(); } } @@ -122,7 +141,8 @@ public sealed class DeviceActionRunner var state = states.FirstOrDefault(item => string.Equals(item.EntityId, command.EntityId, StringComparison.OrdinalIgnoreCase)); var actual = state is null ? null : command.ExpectedAttribute is null ? state.State : state.GetStringAttribute(command.ExpectedAttribute); - if (state is not null && IsAvailable(state) && actual is not null && Matches(actual, command.ExpectedState!)) + if (state is not null && IsAvailable(state) && actual is not null && Matches(actual, command.ExpectedState!) && + (command.ExpectedEntityState is null || Matches(state.State, command.ExpectedEntityState))) { action.Status = "Succeeded"; action.Message = round == 0 ? "已回读确认目标状态" : "重试后已回读确认目标状态"; pending.Remove(command); continue; @@ -130,6 +150,8 @@ public sealed class DeviceActionRunner if (round == attempts - 1) { action.Status = "Failed"; action.Message = $"未确认目标{(command.ExpectedAttribute is null ? "状态" : command.ExpectedAttribute)} {command.ExpectedState}(当前:{actual ?? "未读取到"})"; + if (command.ExpectedEntityState is not null) + action.Message += $",同时要求状态 {command.ExpectedEntityState}(当前:{state?.State ?? "未读取到"})"; pending.Remove(command); continue; } if (!command.RetryOnVerificationMismatch) continue; diff --git a/Evergreen.Health.Application/Health/Services/HealthIb43Service.cs b/Evergreen.Health.Application/Health/Services/HealthIb43Service.cs index a8ade2f..7bf71ab 100644 --- a/Evergreen.Health.Application/Health/Services/HealthIb43Service.cs +++ b/Evergreen.Health.Application/Health/Services/HealthIb43Service.cs @@ -1,5 +1,6 @@ using Evergreen.Health.Application.Health.Entities; using Evergreen.Health.Application.Health.Integrations.Ib43; +using Evergreen.Health.Application.Health.Sleep; using Furion.FriendlyException; using Microsoft.AspNetCore.Http; using Microsoft.Extensions.Options; @@ -26,11 +27,14 @@ public sealed record Ib43CurrentOutput(Ib43BedOutput Bed, string Transport, bool IReadOnlyDictionary Readings); public sealed record Ib43HistoryItem(long Id, long ReceivedAtMs, IReadOnlyDictionary Readings); public sealed record Ib43HistoryOutput(IReadOnlyList Items, long? NextBeforeId); +public sealed record Ib43AutomationRecord(string Id, string ActionKey, string TargetLabel, long TriggeredAtMs, long? CompletedAtMs, string Result); +public sealed record Ib43AutomationOutput(bool Available, bool Enabled, string ActionLabel, string TargetLabel, + IReadOnlyList Records, IReadOnlyList Rules); [Authorize] [ApiDescriptionSettings("健康管理", Name = "HealthIb43", Order = 53)] public sealed class HealthIb43Service(Ib43DataRepository repository, IHttpContextAccessor context, - IOptions options, Ib43StateStore states, TimeProvider clock) : IDynamicApiController, ITransient + IOptions options, Ib43StateStore states, TimeProvider clock, BedPresenceAutomation automation) : IDynamicApiController, ITransient { [HttpGet("/api/health/ib43/admin/bindings")] public async Task ActiveBindings(string productKey, string serialNumber) @@ -69,8 +73,49 @@ public sealed class HealthIb43Service(Ib43DataRepository repository, IHttpContex { var admin = await RequireAdmin(); await repository.Release(bindingId, admin, clock.GetUtcNow().ToUnixTimeMilliseconds()); + automation.RevokeBinding(bindingId); } + [HttpGet("/api/health/ib43/bindings/{bindingId}/automation")] + public async Task AutomationStatus(string bindingId) + { + var user = await RequireUser(); + var binding = await repository.OwnedBinding(user, bindingId); + var records = await repository.AutomationHistory(user, binding.Id); + var bed = AutomationBinding(binding); + var canManage = CanManageAutomation(binding); + var primary = canManage ? automation.PrimaryRuleStatus(bed) : null; + return new(primary is not null, primary?.Enabled == true, primary?.ActionLabel ?? "", + primary?.TargetLabel ?? "", records.Select(row => new Ib43AutomationRecord(row.Id, row.ActionKey, + row.TargetLabel, row.TriggeredAtMs, row.CompletedAtMs, row.Result)).ToArray(), + canManage ? automation.RuleStatuses(bed) : []); + } + + [HttpPost("/api/health/ib43/bindings/{bindingId}/automation/start")] + public async Task StartAutomation(string bindingId, [FromQuery] string? actionKey = null) + { + var user = await RequireUser(); + var binding = await repository.OwnedBinding(user, bindingId); + if (!CanManageAutomation(binding) || !automation.TryEnable(AutomationBinding(binding), actionKey)) + throw new AppFriendlyException("该床位当前无法开启家具联动", null) { StatusCode = 409 }; + } + + [HttpPost("/api/health/ib43/bindings/{bindingId}/automation/stop")] + public async Task StopAutomation(string bindingId, [FromQuery] string? actionKey = null) + { + var user = await RequireUser(); + var binding = await repository.OwnedBinding(user, bindingId); + if (!automation.Disable(binding.Id, user, actionKey)) + throw new AppFriendlyException("家具联动规则不存在", null) { StatusCode = 404 }; + } + + private bool CanManageAutomation(HealthIb43Binding binding) => binding.EndedAtMs is null && + binding.Side is "left" or "right" && options.Value.Enabled && options.Value.Devices.Any(device => + device.Simulation && device.ProductKey == binding.ProductKey && device.SerialNumber == binding.SerialNumber); + + private static BedAutomationBinding AutomationBinding(HealthIb43Binding binding) => new(binding.Id, binding.UserId, + binding.ProductKey, binding.SerialNumber, binding.Side == "left" ? BedSide.Left : BedSide.Right, binding.Simulation); + [HttpGet("/api/health/ib43/bindings")] public async Task Bindings() { diff --git a/Evergreen.Health.Application/Health/Sleep/BedPresenceAutomation.cs b/Evergreen.Health.Application/Health/Sleep/BedPresenceAutomation.cs index c1188ac..5b38a73 100644 --- a/Evergreen.Health.Application/Health/Sleep/BedPresenceAutomation.cs +++ b/Evergreen.Health.Application/Health/Sleep/BedPresenceAutomation.cs @@ -6,7 +6,7 @@ using System.Threading.Channels; namespace Evergreen.Health.Application.Health.Sleep; public enum BedSide { Left, Right } -public enum BedAutomationMode { Disabled, Preview, Execute } +public enum BedAutomationMode { Disabled, Preview, Execute, Managed } public sealed record BedPresenceSample(string BindingId, string ProductKey, string SerialNumber, BedSide Side, bool Simulation, BedPresence Presence, DateTimeOffset ReceivedAt, Guid ConnectionId); @@ -20,12 +20,19 @@ public interface IBedPresenceAction { string Key { get; } string Description { get; } + bool PreviewOnly => false; + string? ConditionLabel => null; TimeSpan ExecutionTimeout => TimeSpan.FromSeconds(25); bool SupportsTarget(string entityId); Task ExecuteAsync(string entityId, CancellationToken token); } -public sealed class BedPresenceAutomationOptions +public sealed class BedPresenceAutomationOptions : BedPresenceRuleOptions +{ + public List AdditionalRules { get; set; } = []; +} + +public class BedPresenceRuleOptions { public BedAutomationMode Mode { get; set; } public string BindingId { get; set; } = ""; @@ -34,14 +41,29 @@ public sealed class BedPresenceAutomationOptions public BedSide Side { get; set; } = BedSide.Left; public string ActionKey { get; set; } = ""; public string TargetEntityId { get; set; } = ""; + public string TargetLabel { 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 IsValid() + { + if (Mode == BedAutomationMode.Disabled) return true; + var bindingValid = Mode switch + { + BedAutomationMode.Managed => string.IsNullOrEmpty(BindingId) || Guid.TryParseExact(BindingId, "N", out _), + BedAutomationMode.Preview => Guid.TryParseExact(BindingId, "N", out _), + _ => false + }; + return bindingValid && !string.IsNullOrWhiteSpace(ProductKey) && !string.IsNullOrWhiteSpace(SerialNumber) && + Enum.IsDefined(Side) && !string.IsNullOrWhiteSpace(ActionKey) && ActionKey.Length <= 64 && + !string.IsNullOrWhiteSpace(TargetEntityId) && TargetEntityId.Length <= 128 && (TargetLabel?.Length ?? 0) <= 128; + } - public bool Matches(BedPresenceSample sample) => sample.Simulation && sample.BindingId == BindingId && - sample.ProductKey == ProductKey && sample.SerialNumber == SerialNumber && sample.Side == Side; + public bool Matches(BedPresenceSample sample) => MatchesSource(sample.BindingId, sample.ProductKey, sample.SerialNumber, sample.Side, sample.Simulation); + + public bool Matches(BedAutomationBinding binding) => MatchesSource(binding.Id, binding.ProductKey, binding.SerialNumber, binding.Side, binding.Simulation); + + private bool MatchesSource(string id, string productKey, string serialNumber, BedSide side, bool simulation) => + simulation && (id == BindingId || Mode == BedAutomationMode.Managed && string.IsNullOrEmpty(BindingId)) && + productKey == ProductKey && serialNumber == SerialNumber && side == Side; } /// 按连接识别连续的离床到在床,首条、重复和超时事件只建立基线。 @@ -66,45 +88,180 @@ public sealed class BedPresenceEdge public void Reset() => _previous = null; } -/// 异步执行单条联调规则。采集线程只提交归一化事件。 public sealed class BedPresenceAutomation(IOptions options, IEnumerable actions, - TimeProvider clock, ILogger logger) : BackgroundService, IBedPresenceSink + TimeProvider clock, ILogger logger, IBedPresenceExecutionLog? executionLog = null) : 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; + private readonly BedPresenceRule[] _rules = CreateRules(options.Value, actions, clock, logger, executionLog); + + public BedPresenceRuleStatus? PrimaryRuleStatus(BedAutomationBinding binding) => _rules[0].Status(binding); + + public BedPresenceRuleStatus[] RuleStatuses(BedAutomationBinding binding) => + _rules.Select(rule => rule.Status(binding)).OfType().ToArray(); + + public bool IsEnabled(string bindingId, long userId, string? actionKey = null) => + FindRule(actionKey)?.IsEnabled(bindingId, userId) == true; + + public bool TryEnable(BedAutomationBinding binding, string? actionKey = null) => + FindRule(actionKey)?.TryEnable(binding) == true; + + public bool Disable(string bindingId, long userId, string? actionKey = null) + { + var rule = FindRule(actionKey); + if (rule is null) return false; + rule.Disable(bindingId, userId); + return true; + } + + public void RevokeBinding(string bindingId) + { + foreach (var rule in _rules) rule.RevokeBinding(bindingId); + } 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"); + foreach (var rule in _rules) rule.Observe(sample, connectionToken); } - protected override async Task ExecuteAsync(CancellationToken stoppingToken) + protected override Task ExecuteAsync(CancellationToken stoppingToken) => + Task.WhenAll(_rules.Select(rule => rule.RunAsync(stoppingToken))); + + public override Task StopAsync(CancellationToken cancellationToken) { - 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)) + foreach (var rule in _rules) rule.Stop(); + return base.StopAsync(cancellationToken); + } + + private BedPresenceRule? FindRule(string? actionKey) => actionKey is null + ? _rules[0] : _rules.FirstOrDefault(rule => rule.ActionKey == actionKey); + + private static BedPresenceRule[] CreateRules(BedPresenceAutomationOptions configuration, + IEnumerable actions, TimeProvider clock, ILogger logger, + IBedPresenceExecutionLog? executionLog) + { + var registeredActions = actions.ToArray(); + var rules = new List { new(configuration, registeredActions, clock, logger, executionLog) }; + if (configuration.Mode != BedAutomationMode.Managed) return rules.ToArray(); + + var keys = new HashSet(StringComparer.Ordinal) { configuration.ActionKey }; + foreach (var rule in configuration.AdditionalRules) { - logger.LogWarning("Bed presence automation inactive: invalid binding, action or target"); + if (rule.Mode != BedAutomationMode.Managed) continue; + if (!keys.Add(rule.ActionKey)) + { + logger.LogWarning("Bed presence automation ignored duplicate action key {Action}", rule.ActionKey); + continue; + } + rules.Add(new(rule, registeredActions, clock, logger, executionLog)); + } + return rules.ToArray(); + } +} + +public sealed record BedPresenceRuleStatus(string ActionKey, string ActionLabel, string TargetLabel, bool Enabled, + bool PreviewOnly, string? ConditionLabel); + +/// 每条规则独立处理上床事件,慢速设备回读不会阻塞其他规则。 +internal sealed class BedPresenceRule(BedPresenceRuleOptions configuration, IReadOnlyList actions, + TimeProvider clock, ILogger logger, IBedPresenceExecutionLog? executionLog) +{ + private sealed class Session(string bindingId, long userId, DateTimeOffset startedAt) + { + private readonly CancellationTokenSource _stop = new(); + public string BindingId { get; } = bindingId; + public long UserId { get; } = userId; + public DateTimeOffset StartedAt { get; } = startedAt; + public CancellationToken Token => _stop.Token; + public void Cancel() => _stop.Cancel(); + } + + private sealed record Item(long Sequence, BedPresenceSample Sample, CancellationToken ConnectionToken, Session? Session); + private readonly Channel _queue = Channel.CreateBounded(new BoundedChannelOptions(32) + { SingleReader = true, FullMode = BoundedChannelFullMode.Wait }); + private long _sequence; + private volatile IBedPresenceAction? _activeAction; + private readonly object _sessionGate = new(); + private volatile Session? _session; + + public string ActionKey => configuration.ActionKey; + private string TargetLabel => string.IsNullOrWhiteSpace(configuration.TargetLabel) ? configuration.TargetEntityId : configuration.TargetLabel; + + public BedPresenceRuleStatus? Status(BedAutomationBinding binding) => + _activeAction is { } action && configuration.Mode == BedAutomationMode.Managed && configuration.Matches(binding) + ? new(action.Key, action.Description, TargetLabel, IsEnabled(binding.Id, binding.UserId), action.PreviewOnly, action.ConditionLabel) + : null; + + public bool IsEnabled(string bindingId, long userId) => _session is { } session && session.BindingId == bindingId && session.UserId == userId; + + public bool TryEnable(BedAutomationBinding binding) + { + Session? previous; + lock (_sessionGate) + { + if (_activeAction is null || configuration.Mode != BedAutomationMode.Managed || !configuration.Matches(binding)) return false; + if (IsEnabled(binding.Id, binding.UserId)) return true; + previous = _session; + _session = new(binding.Id, binding.UserId, clock.GetUtcNow()); + } + previous?.Cancel(); + return true; + } + + public void Disable(string bindingId, long userId) + { + Session? previous; + lock (_sessionGate) + { + if (!IsEnabled(bindingId, userId)) return; + previous = _session; + _session = null; + } + previous?.Cancel(); + } + + public void RevokeBinding(string bindingId) + { + if (_session is { } session && session.BindingId == bindingId) Disable(bindingId, session.UserId); + } + + public void Observe(BedPresenceSample sample, CancellationToken connectionToken) + { + if (_activeAction is null || !configuration.Matches(sample) || connectionToken.IsCancellationRequested) return; + var session = _session; + if (configuration.Mode == BedAutomationMode.Managed && + (session is null || session.BindingId != sample.BindingId || sample.ReceivedAt < session.StartedAt)) return; + if (!_queue.Writer.TryWrite(new(Interlocked.Increment(ref _sequence), sample, connectionToken, session))) + logger.LogWarning("Bed presence automation queue full; observation dropped for {Action}", ActionKey); + } + + public async Task RunAsync(CancellationToken stoppingToken) + { + if (configuration.Mode == BedAutomationMode.Disabled) return; + if (configuration.Mode == BedAutomationMode.Execute) + { + logger.LogWarning("Legacy Execute mode is disabled; use Managed and enable the rule in H5"); + 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) || + configuration.Mode == BedAutomationMode.Managed && executionLog is null) + { + logger.LogWarning("Bed presence automation inactive: invalid binding, action or target for {Action}", ActionKey); return; } var action = candidates[0]; var target = configuration.TargetEntityId; - _accepting = true; + _activeAction = action; 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(); + Session? lastSession = null; long lastSequence = 0; await foreach (var item in _queue.Reader.ReadAllAsync(stoppingToken)) { - if (item.Sequence != lastSequence + 1 || item.ConnectionToken.IsCancellationRequested) edge.Reset(); + if (item.Sequence != lastSequence + 1 || item.ConnectionToken.IsCancellationRequested || item.Session != lastSession) edge.Reset(); lastSequence = item.Sequence; + lastSession = item.Session; + if (configuration.Mode == BedAutomationMode.Managed && (item.Session is null || item.Session != _session)) continue; if (item.ConnectionToken.IsCancellationRequested || !edge.Observe(item.Sample, clock.GetUtcNow())) continue; if (configuration.Mode == BedAutomationMode.Preview) { @@ -117,28 +274,60 @@ public sealed class BedPresenceAutomation(IOptions 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 + await ExecuteManagedAction(item, action, target, stoppingToken); + } + } + + private async Task ExecuteManagedAction(Item item, IBedPresenceAction action, string target, CancellationToken stoppingToken) + { + var session = item.Session!; + using var timeout = new CancellationTokenSource(action.ExecutionTimeout, clock); + using var operation = CancellationTokenSource.CreateLinkedTokenSource(stoppingToken, item.ConnectionToken, session.Token, timeout.Token); + string? id = null; + var result = "failed-unconfirmed"; + try + { + operation.Token.ThrowIfCancellationRequested(); + id = await executionLog!.BeginAsync(session.BindingId, session.UserId, action.Key, TargetLabel, item.Sample.ReceivedAt, operation.Token); + if (id is null) return; + operation.Token.ThrowIfCancellationRequested(); + if (session != _session) throw new OperationCanceledException(); + if (clock.GetUtcNow() - item.Sample.ReceivedAt > TimeSpan.FromSeconds(10)) { - 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); + result = "expired"; + return; } - catch (OperationCanceledException) + result = await action.ExecuteAsync(target, operation.Token); + logger.LogInformation("Bed presence action: Action={Action}, Target={EntityId}, Result={Result}", action.Key, target, result); + } + catch (OperationCanceledException) + { + result = "cancelled-unconfirmed"; + 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); + } + finally + { + if (id is not null) { - 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); + try { await executionLog!.CompleteAsync(id, result, clock.GetUtcNow()); } + catch (Exception error) { logger.LogWarning("Bed presence execution result could not be saved ({Type})", error.GetType().Name); } } } } - public override Task StopAsync(CancellationToken cancellationToken) + public void Stop() { - _accepting = false; - return base.StopAsync(cancellationToken); + Session? previous; + lock (_sessionGate) + { + _activeAction = null; + previous = _session; + _session = null; + } + previous?.Cancel(); } } diff --git a/Evergreen.Health.Application/Health/Sleep/BedPresenceExecution.cs b/Evergreen.Health.Application/Health/Sleep/BedPresenceExecution.cs new file mode 100644 index 0000000..2726f0c --- /dev/null +++ b/Evergreen.Health.Application/Health/Sleep/BedPresenceExecution.cs @@ -0,0 +1,11 @@ +namespace Evergreen.Health.Application.Health.Sleep; + +public sealed record BedAutomationBinding(string Id, long UserId, string ProductKey, string SerialNumber, + BedSide Side, bool Simulation); + +public interface IBedPresenceExecutionLog +{ + Task BeginAsync(string bindingId, long userId, string action, string target, DateTimeOffset triggeredAt, + CancellationToken token); + Task CompleteAsync(string id, string result, DateTimeOffset completedAt); +}