Compare commits

...
2 Commits
Author SHA1 Message Date
fuenmao 9cfd9e24d7 11111 2026-11-13 14:03:25 +08:00
fuenmao 718b138033 111111 2026-11-12 15:45:52 +08:00
34 changed files with 1613 additions and 105 deletions
@@ -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<BedPresenceAutomation>.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<string?> Release { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously);
public TaskCompletionSource<string> Completed { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously);
public Task<string?> 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")]
@@ -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<int>? BeforeRead { get; set; }
public Task<IReadOnlyList<HomeAssistantState>> GetStatesAsync(CancellationToken cancellationToken = default)
{
cancellationToken.ThrowIfCancellationRequested(); Reads++;
BeforeRead?.Invoke(Reads);
return Task.FromResult<IReadOnlyList<HomeAssistantState>>(States.Values.ToArray());
}
public Task CallServiceAsync(string domain, string service, IReadOnlyDictionary<string, object?> 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,
@@ -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<IHomeAssistantGateway>(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<DeviceModuleRegistry>();
var catalog = provider.GetRequiredService<HomeAssistantDeviceCatalog>();
var parameters = provider.GetRequiredService<DeviceParameterController>();
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<InvalidOperationException>(() => parameters.ExecuteAsync(
wakeDevice, gateway.States, "fragrance_quick_release", true, null, default));
await Assert.ThrowsAsync<InvalidOperationException>(() => 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()
{
@@ -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<IReadOnlyList<HomeAssistantState>> 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()!,
@@ -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<TcmMusicTransitionService>.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<BedPresenceAutomation>.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<BedPresenceAutomation>.Instance, executionLog);
var observedPresence = new ObservedPresenceSink(automation);
using var service = new Ib43MqttService(options, new Ib43MqttTransportFactory(), store,
TimeProvider.System, NullLogger<Ib43MqttService>.Instance, sink, automation);
TimeProvider.System, NullLogger<Ib43MqttService>.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<string, TaskCompletionSource<string>> _results = new();
public ConcurrentQueue<(string Action, DateTimeOffset TriggeredAt)> Started { get; } = new();
public ConcurrentQueue<string> Completed { get; } = new();
public Task<string> WaitForResult(string action) => Result(action).Task.WaitAsync(TimeSpan.FromSeconds(3));
private TaskCompletionSource<string> Result(string action) =>
_results.GetOrAdd(action, _ => new(TaskCreationOptions.RunContinuationsAsynchronously));
public Task<string?> 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<string?>(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;
+139 -2
View File
@@ -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<HealthIb43Binding, HealthIb43Observation, HealthUser>();
_db.CodeFirst.InitTables<HealthIb43AutomationExecution>();
_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<BedPresenceAutomation>.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<string> ExecuteAsync(string entityId, CancellationToken token) => throw new InvalidOperationException("No real action in this test");
}
private sealed class TestExecutionLog(Ib43DataRepository repository) : IBedPresenceExecutionLog
{
public Task<string?> 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);
@@ -288,17 +288,27 @@ public sealed class SceneLightingIntegrationTests
}
[Fact]
public void ProductionRegistrationAddsConfiguredStripWithoutCreatingASecondDeviceControlImplementation()
public async Task ProductionRegistrationAddsSceneStripWithIndependentManualPower()
{
var services = new ServiceCollection(); services.AddOptions(); services.AddLogging(); services.AddHttpClient();
services.AddSingleton<IOptions<HomeAssistantOptions>>(Options.Create(Settings()));
services.AddHealthDevices(); services.AddSingleton<IHomeAssistantGateway>(new FakeHaGateway());
var settings = Settings();
settings.AdditionalSleepLights.Single().StartSleepOnManualPowerOn = false;
var gateway = new FakeHaGateway();
gateway.States[Strip] = Light(Strip);
services.AddSingleton<IOptions<HomeAssistantOptions>>(Options.Create(settings));
services.AddHealthDevices(); services.AddSingleton<IHomeAssistantGateway>(gateway);
services.AddSingleton<IAudioPlaybackGateway>(new FakeAudioGateway());
using var provider = services.BuildServiceProvider();
var registry = provider.GetRequiredService<DeviceModuleRegistry>();
Assert.IsType<ConfiguredSleepLightModule>(registry.ForEntities([Strip]));
var strip = Assert.IsType<ConfiguredSleepLightModule>(registry.ForEntities([Strip]));
Assert.IsType<LightingModule>(registry.ForEntities([Primary]));
Assert.IsType<ClimateModule>(registry.ForEntities([Climate]));
await strip.SetPowerAsync(true, default);
var command = Assert.Single(gateway.Calls);
Assert.Equal(Strip, command.EntityId);
Assert.Equal("turn_on", command.Service);
Assert.Single(command.Data!); // 详情页开灯不套用舒睡模式或亮度。
Assert.False(provider.GetRequiredService<HomeAssistantLightTransitionService>().ForEntity(Strip).GetStatus().Running);
}
[Fact]
@@ -96,6 +96,43 @@ public sealed class StandardLightControlTests
Assert.Single(gateway.Calls);
}
[Fact]
public async Task ConfiguredManualLightsAreDiscoveredIndependentlyWithInventoryFilteringEnabled()
{
const string auxiliary = "light.auxiliary", unconfigured = "light.unconfigured";
var gateway = new FakeHaGateway();
foreach (var id in new[] { LightA, LightB, auxiliary, unconfigured }) gateway.States[id] = State(id);
var catalog = new HomeAssistantDeviceCatalog(Options.Create(new HomeAssistantOptions
{
OnlyConfiguredDevices = true, ManualLightEntityIds = [LightA, LightB, LightB]
}), new DeviceModuleRegistry([]));
var haRegistry = new HomeAssistantRegistries
{
Devices = [new() { Id = "a", Name = "顶灯一" }, new() { Id = "b", Name = "顶灯二" }, new() { Id = "other" }],
Entities = [new() { DeviceId = "a", EntityId = auxiliary }, new() { DeviceId = "a", EntityId = LightA },
new() { DeviceId = "b", EntityId = LightB }, new() { DeviceId = "other", EntityId = unconfigured }]
};
var controller = Controller(gateway);
foreach (var registry in new[] { haRegistry, null })
{
var devices = catalog.BuildDiscoveredDevices(registry, gateway.States);
Assert.Equal(2, devices.Count);
var first = Assert.Single(devices, item => item.PrimaryControlEntityId == LightA);
var second = Assert.Single(devices, item => item.PrimaryControlEntityId == LightB);
Assert.NotEqual(first.DeviceId, second.DeviceId);
Assert.All(devices, item =>
{
Assert.Equal(nameof(HealthDeviceCategory.Lighting), item.Category);
Assert.Equal("", item.ModuleKey);
});
await controller.ExecuteAsync(first, gateway.States, "light_brightness", 30, LightA, default);
await controller.ExecuteAsync(second, gateway.States, "light_color_temperature", 3100, LightB, default);
await Assert.ThrowsAsync<InvalidOperationException>(() => controller.ExecuteAsync(
second, gateway.States, "light_brightness", 40, LightA, default));
}
Assert.Equal(new[] { LightA, LightB, LightA, LightB }, gateway.Calls.Select(call => call.EntityId));
}
[Fact]
public async Task TwoLightsUseIndependentRangesAndEffectLists()
{
@@ -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"
}
]
}
}
@@ -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,11 +35,14 @@
"Tvoc": "sensor.mirsz_cn_2027039858_et71_tvoc_density_p_3_8"
},
"OnlyConfiguredDevices": true,
"DisplayOnlyDevices": [
"ManualLightEntityIds": [],
"AdditionalSleepLights": [
{
"Name": "香氛机 唤醒",
"EntityId": "switch.xiaomi_cn_1238447032_xw002_on_p_2_2",
"Category": "Fragrance"
"Key": "roof-light-strip-1",
"Name": "超薄智能灯带",
"EntityId": "light.hekai1_cn_2224036048_wy0a01_s_2_light",
"SleepEffect": "助眠模式",
"StartSleepOnManualPowerOn": false
}
],
"SleepScene": {
@@ -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<HomeAssistantOptions> 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<string> 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";
}
}
@@ -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<HomeAssistantOptions> 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<string> 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<HomeAssistantState> 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<HomeAssistantState> states, string entityId) =>
states.FirstOrDefault(state => string.Equals(state.EntityId, entityId, StringComparison.OrdinalIgnoreCase));
}
@@ -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<HomeAssistantOptions> 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<string> ExecuteAsync(string entityId, CancellationToken token)
{
if (!SupportsTarget(entityId)) return "target-not-configured";
IReadOnlyList<HomeAssistantState> 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"
}
};
}
}
@@ -13,6 +13,7 @@ public sealed record DeviceCommand(string Name, string EntityId, string Domain,
public Action<HomeAssistantState>? ValidateCurrentState { get; init; }
// 不为空时 ExpectedState 表示此属性的期望值,而不是实体的顶层状态。
public string? ExpectedAttribute { get; init; }
public string? ExpectedEntityState { get; init; }
public bool RetryOnVerificationMismatch { get; init; } = true;
public Dictionary<string, object?> ToServiceData()
{
@@ -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<HomeAssistantOptions> 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<string> 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}";
}
}
@@ -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<HomeAssistantOptions> 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<string> 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<HomeAssistantState> 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"
};
}
}
@@ -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<HomeAssistantState> 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";
}
}
@@ -31,10 +31,12 @@ public sealed class DehumidifierModule : HealthDeviceModule
public override string PrimaryEntityId => _entities.Dehumidifier;
public override IReadOnlyList<DeviceParameterDefinition> 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);
@@ -12,8 +12,14 @@ public sealed class HomeAssistantDeviceCatalog
{
private readonly HomeAssistantOptions _options;
private readonly DeviceModuleRegistry _modules;
private readonly HashSet<string> _manualLightIds;
public HomeAssistantDeviceCatalog(IOptions<HomeAssistantOptions> options, DeviceModuleRegistry modules)
=> (_options, _modules) = (options.Value, modules);
{
(_options, _modules) = (options.Value, modules);
_manualLightIds = _options.ManualLightEntityIds
.Where(id => !string.IsNullOrWhiteSpace(id) && id.StartsWith("light.", StringComparison.Ordinal))
.ToHashSet(StringComparer.OrdinalIgnoreCase);
}
public List<HomeAssistantDiscoveredDeviceOutput> BuildDiscoveredDevices(
HomeAssistantRegistries? registries,
@@ -42,7 +48,8 @@ public sealed class HomeAssistantDeviceCatalog
var entityIds = regEntities.Select(entity => entity.EntityId).ToHashSet(StringComparer.OrdinalIgnoreCase);
var module = _modules.ForEntities(entityIds);
var displayOnly = _options.DisplayOnlyDevices.FirstOrDefault(item => entityIds.Contains(item.EntityId));
if (_options.OnlyConfiguredDevices && module is null && displayOnly is null) continue;
var manualLightId = regEntities.FirstOrDefault(entity => _manualLightIds.Contains(entity.EntityId))?.EntityId;
if (_options.OnlyConfiguredDevices && module is null && displayOnly is null && manualLightId is null) continue;
var entityOutputs = new List<HomeAssistantEntityOutput>();
foreach (var regEntity in regEntities)
@@ -80,9 +87,11 @@ public sealed class HomeAssistantDeviceCatalog
? an
: null;
var category = module?.Category ?? displayOnly?.Category ?? CategorizeDevice(displayName, device.Model ?? "", device.Manufacturer ?? "", entityOutputs);
var category = module?.Category ?? (manualLightId is not null ? nameof(HealthDeviceCategory.Lighting) :
displayOnly?.Category ?? CategorizeDevice(displayName, device.Model ?? "", device.Manufacturer ?? "", entityOutputs));
var metrics = ExtractDeviceMetrics(category, entityOutputs);
var primaryControlEntityId = module is null && displayOnly is not null ? null : FindPrimaryControlEntityId(category, entityOutputs);
var primaryControlEntityId = module is not null ? FindPrimaryControlEntityId(category, entityOutputs) :
manualLightId ?? (displayOnly is not null ? null : FindPrimaryControlEntityId(category, entityOutputs));
discovered.Add(new HomeAssistantDiscoveredDeviceOutput
{
@@ -137,9 +146,27 @@ public sealed class HomeAssistantDeviceCatalog
Entities = entityOutputs, Metrics = ExtractDeviceMetrics(module.Category, entityOutputs)
});
}
foreach (var id in _manualLightIds)
{
if (_modules.ForEntities([id]) is not null ||
_options.ExcludedDeviceEntityIds.Contains(id, StringComparer.OrdinalIgnoreCase) ||
!stateMap.TryGetValue(id, out var state)) continue;
var name = state.GetStringAttribute("friendly_name") ?? id;
var available = IsAvailable(state);
list.Add(new()
{
DeviceId = $"fallback_{id}", Name = name, Category = nameof(HealthDeviceCategory.Lighting), Online = available,
PrimaryControlEntityId = state.EntityId, Entities = [new()
{
EntityId = state.EntityId, Domain = "light", Name = name, State = state.State,
Available = available, Attributes = ExtractSafeAttributes(state)
}]
});
}
foreach (var device in _options.DisplayOnlyDevices)
{
if (string.IsNullOrWhiteSpace(device.EntityId) || _modules.ForEntities([device.EntityId]) is not null ||
_manualLightIds.Contains(device.EntityId) ||
_options.ExcludedDeviceEntityIds.Contains(device.EntityId, StringComparer.OrdinalIgnoreCase)) continue;
stateMap.TryGetValue(device.EntityId, out var state);
var available = state is not null && IsAvailable(state);
@@ -13,22 +13,30 @@ public sealed class IndoorEnvironmentReader
public IndoorEnvironmentReader(IOptions<HomeAssistantOptions> options, TimeProvider time)
=> (_entities, _time) = (options.Value.Entities, time);
public IndoorTemperatureSample Read(IReadOnlyDictionary<string, HomeAssistantState> states)
public IndoorTemperatureSample Read(IReadOnlyDictionary<string, HomeAssistantState> states) =>
ReadState(string.IsNullOrWhiteSpace(_entities.Temperature) ? null : states.GetValueOrDefault(_entities.Temperature));
public IndoorTemperatureSample Read(IReadOnlyList<HomeAssistantState> 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, "室内温度数据有效");
}
}
@@ -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<HomeAssistantOptions> 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<string> 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";
}
}
@@ -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<HomeAssistantOptions> options) : base(runner)
private readonly bool _wakeDevice;
public FragranceModule(DeviceActionRunner runner, IOptions<HomeAssistantOptions> options)
: this(runner, options, wakeDevice: false) { }
internal FragranceModule(DeviceActionRunner runner, IOptions<HomeAssistantOptions> 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<string, object?> { ["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<DeviceParameterDefinition> 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<DeviceCommand> GetShutdownCommands() =>
public override IEnumerable<DeviceCommand> 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 ? "开启" : "关闭" });
}
@@ -34,6 +34,16 @@ public static class HealthDeviceRegistration
services.AddOptions<BedPresenceAutomationOptions>();
services.AddSingleton<IBedPresenceAction, BedPresenceSleepLightAction>();
services.AddSingleton<IBedPresenceAction, BedPresenceCurtainCloseAction>();
services.AddSingleton<IBedPresenceAction, BedPresenceDehumidifierPreview>();
services.AddSingleton<IBedPresenceAction, BedPresenceDehumidifierStartAction>();
services.AddSingleton<IBedPresenceAction, BedPresenceClimatePreview>();
services.AddSingleton<IBedPresenceAction, BedPresenceClimateHeatAction>();
services.AddSingleton<IBedPresenceAction>(provider => new BedPresenceFragranceReleaseAction(
provider.GetRequiredService<DeviceActionRunner>(), provider.GetRequiredService<IOptions<HomeAssistantOptions>>(), wakeDevice: false));
services.AddSingleton<IBedPresenceAction>(provider => new BedPresenceFragranceReleaseAction(
provider.GetRequiredService<DeviceActionRunner>(), provider.GetRequiredService<IOptions<HomeAssistantOptions>>(), wakeDevice: true));
services.AddSingleton<IBedPresenceAction, BedPresenceSleepMusicAction>();
services.AddSingleton<IBedPresenceExecutionLog, Ib43AutomationExecutionLog>();
services.AddSingleton<BedPresenceAutomation>();
services.AddSingleton<IBedPresenceSink>(provider => provider.GetRequiredService<BedPresenceAutomation>());
services.AddHostedService(provider => provider.GetRequiredService<BedPresenceAutomation>());
@@ -74,6 +84,8 @@ public static class HealthDeviceRegistration
services.AddSingleton<IHealthDeviceModule>(provider => provider.GetRequiredService<DehumidifierModule>());
services.AddSingleton<FragranceModule>();
services.AddSingleton<IHealthDeviceModule>(provider => provider.GetRequiredService<FragranceModule>());
services.AddSingleton<IHealthDeviceModule>(provider => new FragranceModule(
provider.GetRequiredService<DeviceActionRunner>(), provider.GetRequiredService<IOptions<HomeAssistantOptions>>(), wakeDevice: true));
services.AddSingleton<LightingModule>(provider => new(
provider.GetRequiredService<DeviceActionRunner>(), provider.GetRequiredService<IOptions<HomeAssistantOptions>>(),
provider.GetRequiredService<HomeAssistantLightTransitionService>().ForEntity(
@@ -25,8 +25,17 @@ public sealed class ConfiguredSleepLightModule : HealthDeviceModule
}
public override async Task SetPowerAsync(bool turnOn, CancellationToken token)
{
if (turnOn) { await _transition.StartSleepAsync(2700, token); return; }
if (turnOn && _light.StartSleepOnManualPowerOn)
{
await _transition.StartSleepAsync(2700, token);
return;
}
await _transition.CancelAsync(token);
if (turnOn)
{
await base.SetPowerAsync(true, token);
return;
}
var command = GetShutdownCommands().Single();
await Runner.SendAsync(command, token);
await Runner.VerifyParameterAsync(command, token);
@@ -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";
}
@@ -12,6 +12,8 @@ public sealed class HomeAssistantOptions : IConfigurableOptions
public List<string> ExcludedDeviceEntityIds { get; set; } = [];
public bool OnlyConfiguredDevices { get; set; }
public List<DisplayOnlyDeviceOptions> DisplayOnlyDevices { get; set; } = [];
// 每条独立灯具登记自己的主 light 实体,名称和可控能力读取自 HA。
public List<string> ManualLightEntityIds { get; set; } = [];
public HomeAssistantEntityOptions Entities { get; set; } = new();
public HomeAssistantSleepSceneOptions SleepScene { get; set; } = new();
public List<SceneLightOptions> AdditionalSleepLights { get; set; } = [];
@@ -31,6 +33,7 @@ public sealed class SceneLightOptions
public string Name { get; set; } = string.Empty;
public string EntityId { get; set; } = string.Empty;
public string? SleepEffect { get; set; }
public bool StartSleepOnManualPowerOn { get; set; } = true;
}
public sealed class HomeAssistantEntityOptions
@@ -46,6 +49,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;
@@ -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<string?> BeginAsync(string bindingId, long userId, string action, string target,
DateTimeOffset triggeredAt, CancellationToken token)
{
using var scope = scopes.CreateScope();
return await scope.ServiceProvider.GetRequiredService<Ib43DataRepository>()
.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<Ib43DataRepository>()
.CompleteAutomation(id, result, completedAt.ToUnixTimeMilliseconds());
}
}
@@ -31,6 +31,26 @@ public sealed class Ib43DataRepository(ISqlSugarClient db)
await db.Queryable<HealthIb43Binding>().Where(b => b.Id == id && b.UserId == userId).FirstAsync()
?? throw new AppFriendlyException("床位绑定不存在或无权访问", null) { StatusCode = 404 };
public Task<List<HealthIb43AutomationExecution>> AutomationHistory(long userId, string bindingId) =>
db.Queryable<HealthIb43AutomationExecution>().Where(row => row.UserId == userId && row.BindingId == bindingId)
.OrderBy(row => row.TriggeredAtMs, OrderByType.Desc).Take(20).ToListAsync();
public async Task<string?> BeginAutomation(string bindingId, long userId, string action, string target, long triggeredAtMs, CancellationToken token)
{
token.ThrowIfCancellationRequested();
if (!await UserEnabled(userId) || !await db.Queryable<HealthIb43Binding>().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<int> CompleteAutomation(string id, string result, long completedAtMs) => db.Updateable<HealthIb43AutomationExecution>()
.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<HealthIb43Binding>().AnyAsync(b => b.ActiveSlot == binding.ActiveSlot))
@@ -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<bool> TryStartIdlePlaybackAsync(string constitutionCode, CancellationToken cancellationToken) =>
StartAsync(constitutionCode, onlyIfIdle: true, cancellationToken);
private async Task<bool> 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(); }
}
@@ -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
{
@@ -25,13 +25,22 @@ public sealed class DeviceActionRunner
public async Task<HomeAssistantState> 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<HomeAssistantState> 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<string?> SendIfAsync(DeviceCommand command,
Func<IReadOnlyList<HomeAssistantState>, 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<HomeAssistantState>? 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;
@@ -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<string, Ib43MetricOutput> Readings);
public sealed record Ib43HistoryItem(long Id, long ReceivedAtMs, IReadOnlyDictionary<string, Ib43Reading> Readings);
public sealed record Ib43HistoryOutput(IReadOnlyList<Ib43HistoryItem> 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<Ib43AutomationRecord> Records, IReadOnlyList<BedPresenceRuleStatus> Rules);
[Authorize]
[ApiDescriptionSettings("健康管理", Name = "HealthIb43", Order = 53)]
public sealed class HealthIb43Service(Ib43DataRepository repository, IHttpContextAccessor context,
IOptions<Ib43Options> options, Ib43StateStore states, TimeProvider clock) : IDynamicApiController, ITransient
IOptions<Ib43Options> options, Ib43StateStore states, TimeProvider clock, BedPresenceAutomation automation) : IDynamicApiController, ITransient
{
[HttpGet("/api/health/ib43/admin/bindings")]
public async Task<Ib43AdminBedOutput[]> 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<Ib43AutomationOutput> 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<Ib43BedOutput[]> Bindings()
{
@@ -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<string> ExecuteAsync(string entityId, CancellationToken token);
}
public sealed class BedPresenceAutomationOptions
public sealed class BedPresenceAutomationOptions : BedPresenceRuleOptions
{
public List<BedPresenceRuleOptions> 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;
}
/// <summary>按连接识别连续的离床到在床,首条、重复和超时事件只建立基线。</summary>
@@ -66,45 +88,180 @@ public sealed class BedPresenceEdge
public void Reset() => _previous = null;
}
/// <summary>异步执行单条联调规则。采集线程只提交归一化事件。</summary>
public sealed class BedPresenceAutomation(IOptions<BedPresenceAutomationOptions> options, IEnumerable<IBedPresenceAction> actions,
TimeProvider clock, ILogger<BedPresenceAutomation> logger) : BackgroundService, IBedPresenceSink
TimeProvider clock, ILogger<BedPresenceAutomation> logger, IBedPresenceExecutionLog? executionLog = null) : BackgroundService, IBedPresenceSink
{
private sealed record Item(long Sequence, BedPresenceSample Sample, CancellationToken ConnectionToken);
private readonly Channel<Item> _queue = Channel.CreateBounded<Item>(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<BedPresenceRuleStatus>().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<IBedPresenceAction> actions, TimeProvider clock, ILogger<BedPresenceAutomation> logger,
IBedPresenceExecutionLog? executionLog)
{
var registeredActions = actions.ToArray();
var rules = new List<BedPresenceRule> { new(configuration, registeredActions, clock, logger, executionLog) };
if (configuration.Mode != BedAutomationMode.Managed) return rules.ToArray();
var keys = new HashSet<string>(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);
/// <summary>每条规则独立处理上床事件,慢速设备回读不会阻塞其他规则。</summary>
internal sealed class BedPresenceRule(BedPresenceRuleOptions configuration, IReadOnlyList<IBedPresenceAction> actions,
TimeProvider clock, ILogger<BedPresenceAutomation> 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<Item> _queue = Channel.CreateBounded<Item>(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<BedPresenceAutomationOptions>
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();
}
}
@@ -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<string?> BeginAsync(string bindingId, long userId, string action, string target, DateTimeOffset triggeredAt,
CancellationToken token);
Task CompleteAsync(string id, string result, DateTimeOffset completedAt);
}