Compare commits

...
14 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
fuenmao 61e25175db 1111 2026-11-11 16:11:41 +08:00
fuenmao 3ca90c4cf3 去掉老床垫相关功能 2026-11-09 16:10:05 +08:00
fuenmao 2d44cc3966 11 2026-10-09 18:06:11 +08:00
fuenmao e6b90a06ab 1 2026-10-09 16:46:30 +08:00
fuenmao 4a634ef4c2 1111 2026-10-08 10:16:43 +08:00
fuenmao af596104e2 111 2026-09-28 16:36:41 +08:00
fuenmao 973efa34cc 111 2026-09-24 16:12:47 +08:00
fuenmao 6a622c9bd5 111 2026-09-23 18:19:40 +08:00
fuenmao db7bd2231a 1111 2026-09-23 15:30:40 +08:00
fuenmao f61b3ce3e7 111 2026-09-22 16:55:13 +08:00
fuenmao c1c86b56b6 111 2026-09-22 15:01:29 +08:00
fuenmao a063be61cd 111 2026-09-22 14:16:49 +08:00
114 changed files with 10013 additions and 549 deletions
+3
View File
@@ -20,3 +20,6 @@ logs/
# OS files # OS files
.DS_Store .DS_Store
Thumbs.db Thumbs.db
# Local MiAirX credentials; use MiAirX.example.json as a template.
Evergreen.Health.Application/Configuration/MiAirX.json
+6
View File
@@ -18,9 +18,15 @@
<PackageReference Include="Selenium.WebDriver" Version="4.40.0" /> <PackageReference Include="Selenium.WebDriver" Version="4.40.0" />
<PackageReference Include="Selenium.WebDriver.MSEdgeDriver" Version="144.0.3719.82" /> <PackageReference Include="Selenium.WebDriver.MSEdgeDriver" Version="144.0.3719.82" />
<PackageReference Include="xunit.v3.assert" Version="3.2.2" /> <PackageReference Include="xunit.v3.assert" Version="3.2.2" />
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.4">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers</IncludeAssets>
</PackageReference>
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<None Include="..\..\docs\protocols\ib43\examples\*.json" Link="Ib43Examples\%(Filename)%(Extension)" CopyToOutputDirectory="PreserveNewest" />
<ProjectReference Include="..\Admin.NET.Core\Admin.NET.Core.csproj" /> <ProjectReference Include="..\Admin.NET.Core\Admin.NET.Core.csproj" />
<ProjectReference Include="..\Evergreen.Health.Application\Evergreen.Health.Application.csproj" />
</ItemGroup> </ItemGroup>
</Project> </Project>
@@ -0,0 +1,319 @@
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;
using Evergreen.Health.Application.Health.Sleep;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using System.Text.Json;
using Xunit;
namespace Admin.NET.Test.Health;
public sealed class BedPresenceAutomationTests
{
private const string Binding = "a98788f2aeeb40b68f145a182d99f832";
private const string Light = "light.test_bedroom";
private const string Curtain = "cover.test_curtain";
private 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);
[Fact]
public void Edge_requires_fresh_empty_baseline_on_the_same_bed_and_connection()
{
var edge = new BedPresenceEdge();
var at = DateTimeOffset.UtcNow;
var connection = Guid.NewGuid();
Assert.False(edge.Observe(Sample(BedPresence.Occupied, at, connection), at));
Assert.False(edge.Observe(Sample(BedPresence.Empty, at, connection), at));
Assert.True(edge.Observe(Sample(BedPresence.Occupied, at, connection), at));
Assert.False(edge.Observe(Sample(BedPresence.Occupied, at, connection), at));
Assert.False(edge.Observe(Sample(BedPresence.Empty, at.AddSeconds(-1), connection), at));
Assert.False(edge.Observe(Sample(BedPresence.Occupied, at, connection), at));
Assert.False(edge.Observe(Sample(BedPresence.Empty, at, connection), at));
Assert.False(edge.Observe(Sample(BedPresence.Occupied, at.AddSeconds(61), connection), at.AddSeconds(61)));
Assert.False(edge.Observe(Sample(BedPresence.Empty, at, connection), at.AddSeconds(61)));
Assert.False(edge.Observe(Sample(BedPresence.Occupied, at, Guid.NewGuid()), at));
}
[Fact]
public async Task Preview_requires_configured_simulation_identity_and_never_executes_action()
{
var options = Options.Create(new BedPresenceAutomationOptions
{ Mode = BedAutomationMode.Preview, BindingId = Binding, ActionKey = "curtain-close", TargetEntityId = Curtain });
var action = new NeverExecuteAction();
var logger = new PreviewLogger();
using var automation = new BedPresenceAutomation(options, [action], TimeProvider.System, logger);
await automation.StartAsync(default);
try
{
var connection = Guid.NewGuid();
var at = DateTimeOffset.UtcNow;
var occupied = Sample(BedPresence.Occupied, at, connection);
var empty = occupied with { Presence = BedPresence.Empty };
// 这些样本即使构成上床边沿也不能进入规则。
Assert.False(options.Value.Matches(empty with { Side = BedSide.Right }));
Assert.False(options.Value.Matches(empty with { Simulation = false }));
Assert.False(options.Value.Matches(empty with { BindingId = "someone-else" }));
automation.Observe(occupied, default);
automation.Observe(empty, default);
automation.Observe(occupied, default);
await logger.Preview.Task.WaitAsync(TimeSpan.FromSeconds(3));
Assert.Contains("Action=curtain-close", logger.Preview.Task.Result);
Assert.Contains(Curtain, logger.Preview.Task.Result);
Assert.Equal(0, action.Calls);
}
finally { await automation.StopAsync(default); }
}
[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")]
[InlineData("opening", "state-not-open")]
public async Task Curtain_action_leaves_closed_or_moving_curtain_alone(string state, string expected)
{
var (action, gateway) = CurtainFixture(state, 2, "closed");
Assert.Equal(expected, await action.ExecuteAsync(Curtain, default));
Assert.Empty(gateway.Calls);
}
[Theory]
[InlineData("closed", "readback-confirmed")]
[InlineData("closing", "sent-readback-unconfirmed")]
public async Task Curtain_action_sends_one_close_and_waits_for_closed_readback(string reported, string expected)
{
var (action, gateway) = CurtainFixture("open", 2, reported);
Assert.Equal("target-not-configured", await action.ExecuteAsync("cover.other", default));
Assert.Equal(expected, await action.ExecuteAsync(Curtain, default));
var command = Assert.Single(gateway.Calls);
Assert.Equal(Curtain, command.EntityId);
Assert.Equal("close_cover", command.Service);
}
[Fact]
public async Task Curtain_action_requires_close_capability_before_sending()
{
var (action, gateway) = CurtainFixture("open", 1, "closed");
await Assert.ThrowsAsync<InvalidOperationException>(() => action.ExecuteAsync(Curtain, default));
Assert.Empty(gateway.Calls);
}
private static (BedPresenceCurtainCloseAction, FakeHaGateway) CurtainFixture(string state, int features, string closeResult)
{
var gateway = new FakeHaGateway { CoverCloseResult = closeResult };
gateway.States[Curtain] = new() { EntityId = Curtain, State = state,
Attributes = JsonSerializer.SerializeToElement(new { supported_features = features }) };
var options = Options.Create(new HomeAssistantOptions { Entities = new() { Curtain = Curtain } });
var runner = new DeviceActionRunner(gateway, TimeProvider.System, new() { VerificationDelay = TimeSpan.Zero });
return (new(new CurtainModule(runner, options), runner, options), gateway);
}
[Fact]
public async Task Light_action_starts_only_target_and_verifies_brightness_without_restarting_active_fade()
{
var gateway = new FakeHaGateway();
var time = new ManualTestTimeProvider();
gateway.States[Light] = new() { EntityId = Light, State = "off", Attributes = JsonSerializer.SerializeToElement(new { }) };
gateway.States["light.other"] = new() { EntityId = "light.other", State = "off" };
var options = Options.Create(new HomeAssistantOptions { Entities = new() { AmbientLight = Light } });
var lights = new HomeAssistantLightTransitionService(gateway, new LightOffDispatcher([]), options,
NullLogger<HomeAssistantLightTransitionService>.Instance, new(), time);
var execution = new DeviceExecutionOptions { VerificationDelay = TimeSpan.Zero };
var action = new BedPresenceSleepLightAction(lights, new DeviceActionRunner(gateway, time, execution), execution, options);
try
{
Assert.Equal("target-not-configured", await action.ExecuteAsync("light.other", default));
Assert.Empty(gateway.Calls);
Assert.Equal("readback-confirmed", await action.ExecuteAsync(Light, default));
Assert.Equal("already-running", await action.ExecuteAsync(Light, default));
var call = Assert.Single(gateway.Calls);
Assert.Equal(Light, call.EntityId);
Assert.Equal(50, call.Data!["brightness_pct"]);
Assert.Equal("off", gateway.States["light.other"].State);
}
finally { await lights.StopAsync(default); }
}
private sealed class NeverExecuteAction : IBedPresenceAction
{
public int Calls;
public string Key => "curtain-close";
public string Description => "关闭窗帘";
public bool SupportsTarget(string entityId) => entityId == Curtain;
public Task<string> ExecuteAsync(string entityId, CancellationToken token)
{ Interlocked.Increment(ref Calls); throw new InvalidOperationException("Preview cannot execute"); }
}
private sealed class PreviewLogger : ILogger<BedPresenceAutomation>
{
public TaskCompletionSource<string> Preview { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously);
public IDisposable BeginScope<TState>(TState state) where TState : notnull => null!;
public bool IsEnabled(LogLevel logLevel) => true;
public void Log<TState>(LogLevel logLevel, EventId eventId, TState state, Exception? exception,
Func<TState, Exception?, string> formatter)
{
var message = formatter(state, exception);
if (message.StartsWith("Bed presence preview:", StringComparison.Ordinal)) Preview.TrySetResult(message);
}
}
}
@@ -0,0 +1,42 @@
using Evergreen.Health.Application.Health.Devices.Environment;
using Evergreen.Health.Application.Health.Integrations.HomeAssistant;
using Evergreen.Health.Application.Health.Services;
using Microsoft.Extensions.Options;
using System.Text.Json;
using Xunit;
namespace Admin.NET.Test.Health;
public sealed class ClimateAdviceBindingTests
{
[Theory]
[InlineData("", "15", "heat", true)]
[InlineData("climate.room", "15", "heat", false)]
[InlineData("climate.room", "28", "cool", false)]
[InlineData("", "24", "off", false)]
public void AdviceUsesBindingNotPowerStateToReportPendingIntegration(
string climate, string temperature, string mode, bool pending)
{
var options = Options.Create(new HomeAssistantOptions
{
Entities = new() { Climate = climate, Temperature = "sensor.room" }
});
var gateway = new FakeHaGateway();
var states = new Dictionary<string, HomeAssistantState>
{
["sensor.room"] = new()
{
EntityId = "sensor.room", State = temperature,
LastReported = DateTimeOffset.UtcNow,
Attributes = JsonSerializer.SerializeToElement(new { unit_of_measurement = "°C" })
}
};
var service = new EnvironmentAdviceService(gateway,
new IndoorEnvironmentReader(options, TimeProvider.System), options);
var output = service.Get(null, states);
Assert.Equal(mode, output.ClimateAdvice.Mode);
Assert.Equal(pending, output.ClimateAdvice.PendingIntegration);
Assert.Null(output.ClimateAdvice.TargetTemperatureCelsius);
Assert.Empty(gateway.Calls);
}
}
@@ -0,0 +1,491 @@
using Evergreen.Health.Application.Health.Devices;
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;
using Microsoft.Extensions.Options;
using System.Text.Json;
using Xunit;
namespace Admin.NET.Test.Health;
/// <summary>只使用内存网关,不启动后端、不连接 HA、不触碰实际设备。</summary>
public sealed class ClimateCurtainIntegrationTests
{
private const string ClimateId = "climate.test_room", CurtainId = "cover.test_curtain";
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()
{
EntityId = ClimateId, State = state, Attributes = JsonSerializer.SerializeToElement(new Dictionary<string, object?>
{
["hvac_modes"] = new[] { "cool", "heat", "fan_only", "off" }, ["supported_features"] = features,
["temperature"] = 23m, ["current_temperature"] = 24.1m, ["temperature_unit"] = "°C",
["min_temp"] = minimum, ["max_temp"] = maximum, ["target_temp_step"] = step,
["fan_modes"] = new[] { "自动", "低风", "中风", "高风" }, ["fan_mode"] = "低风"
})
};
private static HomeAssistantState CurtainState(string state = "open", int features = 15) => new()
{
EntityId = CurtainId, State = state,
Attributes = JsonSerializer.SerializeToElement(new { supported_features = features, current_position = 40, device_class = "curtain" })
};
private static PersonalizedSceneConfig Scene(string mode = "heat", int target = 24) => new()
{
ClimateAdvice = new() { Mode = mode, RecommendedMinimumCelsius = 22, RecommendedMaximumCelsius = 25,
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)]
[InlineData(35)]
public async Task TemperatureUsesActualBoundsHalfDegreeAndAttributeReadback(double value)
{
var gateway = new Gateway(ClimateState());
var module = Climate(gateway);
await module.ExecuteParameterAsync(module.BuildParameterCommand("climate_temperature", value), default);
Assert.Equal("off", gateway.States[ClimateId].State); // 顶层不是温度,但属性读回成功。
Assert.Equal(value, gateway.States[ClimateId].GetDoubleAttribute("temperature"));
Assert.Equal("set_temperature", Assert.Single(gateway.Calls).Service);
}
[Theory]
[InlineData(4.5)]
[InlineData(35.5)]
[InlineData(23.25)]
public async Task InvalidTemperatureNeverSends(double value)
{
var gateway = new Gateway(ClimateState());
var module = Climate(gateway);
await Assert.ThrowsAsync<InvalidOperationException>(() => module.ExecuteParameterAsync(
module.BuildParameterCommand("climate_temperature", value), default));
Assert.Empty(gateway.Calls);
}
[Theory]
[InlineData("NaN")]
[InlineData("Infinity")]
[InlineData("not-a-number")]
public void NonfiniteOrMalformedTemperatureIsRejected(string value)
=> Assert.Throws<InvalidOperationException>(() => Climate(new(ClimateState()))
.BuildParameterCommand("climate_temperature", value));
[Fact]
public async Task ActualModeAndFanOptionsAreValidatedAndReadBack()
{
var gateway = new Gateway(ClimateState());
var module = Climate(gateway);
await module.ExecuteParameterAsync(module.BuildParameterCommand("climate_mode", "cool"), default);
await module.ExecuteParameterAsync(module.BuildParameterCommand("climate_fan_mode", "中风"), default);
Assert.Equal("cool", gateway.States[ClimateId].State);
Assert.Equal("中风", gateway.States[ClimateId].GetStringAttribute("fan_mode"));
await Assert.ThrowsAsync<InvalidOperationException>(() => module.ExecuteParameterAsync(
module.BuildParameterCommand("climate_mode", "auto"), default));
await Assert.ThrowsAsync<InvalidOperationException>(() => module.ExecuteParameterAsync(
module.BuildParameterCommand("climate_fan_mode", "超强风"), default));
Assert.Equal(2, gateway.Calls.Count);
}
[Fact]
public async Task FreshCapabilityValidationCannotBeBypassedBySendingCommandDirectly()
{
var gateway = new Gateway(ClimateState());
var runner = Runner(gateway);
var module = new ClimateModule(runner, Options.Create(Settings()));
var command = module.BuildParameterCommand("climate_temperature", 25);
gateway.States[ClimateId] = ClimateState(features: 392); // 构造命令后设备失去目标温度能力。
await Assert.ThrowsAsync<InvalidOperationException>(() => runner.SendAsync(command, default));
Assert.Empty(gateway.Calls);
}
[Fact]
public async Task MissingTemperatureCapabilitiesAndWrongUnitDoNotUseGuessedDefaults()
{
var gateway = new Gateway(ClimateState());
var module = Climate(gateway);
foreach (var attribute in new[] { "min_temp", "max_temp", "target_temp_step", "temperature_unit" })
{
var attributes = ClimateState().Attributes.EnumerateObject().ToDictionary(item => item.Name, item => (object?)item.Value.Clone());
attributes.Remove(attribute);
gateway.States[ClimateId] = new() { EntityId = ClimateId, State = "off", Attributes = JsonSerializer.SerializeToElement(attributes) };
await Assert.ThrowsAsync<InvalidOperationException>(() => module.ExecuteParameterAsync(
module.BuildParameterCommand("climate_temperature", 24), default));
}
Assert.Empty(gateway.Calls);
}
[Fact]
public async Task UnchangedAttributeFailsConfirmationWithoutRepeatedWrites()
{
var gateway = new Gateway(ClimateState()) { ApplyCommands = false };
var module = Climate(gateway);
await Assert.ThrowsAsync<InvalidOperationException>(() => module.ExecuteParameterAsync(
module.BuildParameterCommand("climate_temperature", 25), default));
Assert.Single(gateway.Calls);
Assert.Equal(4, gateway.Reads); // 发送前能力校验一次,随后三轮只读确认。
}
[Fact]
public async Task UnavailableEntityCannotConfirmEvenIfCachedAttributeMatches()
{
var gateway = new Gateway(ClimateState()) { BecomeUnavailableAfterSend = true };
var module = Climate(gateway);
await Assert.ThrowsAsync<InvalidOperationException>(() => module.ExecuteParameterAsync(
module.BuildParameterCommand("climate_temperature", 24), default));
Assert.Single(gateway.Calls);
}
[Theory]
[InlineData("unavailable")]
[InlineData("unknown")]
public async Task OfflineClimateDoesNotSendEvenWhenModeIsKnown(string state)
{
var gateway = new Gateway(ClimateState(state));
var module = Climate(gateway);
await Assert.ThrowsAsync<InvalidOperationException>(() => module.ExecuteParameterAsync(
module.BuildParameterCommand("climate_mode", "heat"), default));
Assert.Empty(gateway.Calls);
}
[Fact]
public async Task SceneUsesPreparedTargetAndConfirmsBothModeAndTemperature()
{
var gateway = new Gateway(ClimateState());
var module = Climate(gateway);
var config = Scene();
var context = new DeviceSceneContext(Runner(gateway), gateway.States, []);
await module.ExecuteSleepAsync(context, config, "balanced", default);
Assert.Equal(24, config.ClimateAdvice.TargetTemperatureCelsius);
Assert.Equal("heat", gateway.States[ClimateId].State);
Assert.Equal(24, gateway.States[ClimateId].GetDoubleAttribute("temperature"));
Assert.Equal(new[] { "set_hvac_mode", "set_temperature" }, gateway.Calls.Select(command => command.Service));
Assert.All(context.Results, result => Assert.Equal("Succeeded", result.Status));
}
[Fact]
public async Task SceneDeterministicallyFitsPreparedTargetToActualIntersection()
{
var gateway = new Gateway(ClimateState(minimum: 24, maximum: 25));
var module = Climate(gateway);
var config = Scene(target: 22);
var context = new DeviceSceneContext(Runner(gateway), gateway.States, []);
await module.ExecuteSleepAsync(context, config, "balanced", default);
Assert.Equal(24, config.ClimateAdvice.TargetTemperatureCelsius);
await module.ExecuteSleepAsync(context, config, "balanced", default);
Assert.Equal(24, config.ClimateAdvice.TargetTemperatureCelsius);
}
[Theory]
[InlineData("off")]
[InlineData("unavailable")]
public async Task ComfortableOrUnknownRoomTemperatureLeavesExistingClimateUntouched(string mode)
{
var gateway = new Gateway(ClimateState("cool"));
var module = Climate(gateway);
var context = new DeviceSceneContext(Runner(gateway), gateway.States, []);
await module.ExecuteSleepAsync(context, Scene(mode), "balanced", default);
Assert.Empty(gateway.Calls);
Assert.Equal("cool", gateway.States[ClimateId].State);
Assert.Equal("Skipped", Assert.Single(context.Results).Status);
}
[Fact]
public async Task SceneNeverStartsWhenRecommendationAndDeviceHaveNoTemperatureIntersection()
{
var gateway = new Gateway(ClimateState(minimum: 30));
var module = Climate(gateway);
var context = new DeviceSceneContext(Runner(gateway), gateway.States, []);
await Assert.ThrowsAsync<InvalidOperationException>(() => module.ExecuteSleepAsync(context, Scene(), "balanced", default));
Assert.Empty(gateway.Calls);
}
[Fact]
public async Task UnsupportedSuggestedModeIsFailureNotFakeSuccessfulScene()
{
var gateway = new Gateway(ClimateState());
var attributes = gateway.States[ClimateId].Attributes.EnumerateObject().ToDictionary(item => item.Name, item => (object?)item.Value.Clone());
attributes["hvac_modes"] = new[] { "cool", "off" };
gateway.States[ClimateId].Attributes = JsonSerializer.SerializeToElement(attributes);
var runner = Runner(gateway);
var coordinator = new HealthSceneCoordinator(new([Climate(gateway)]), runner, gateway, new FakeLight(), new FakeMusic());
var result = await coordinator.ExecuteAsync("sleep", Scene(), "balanced", default);
Assert.Equal("Failed", result.Status);
Assert.Contains(result.Actions, action => action.Status == "Failed" && action.Message.Contains("不支持"));
Assert.Empty(gateway.Calls);
}
[Fact]
public async Task SceneStopsBeforeTemperatureWriteIfModeWasNotConfirmed()
{
var gateway = new Gateway(ClimateState()) { ApplyCommands = false };
var module = Climate(gateway);
var context = new DeviceSceneContext(Runner(gateway), gateway.States, []);
await module.ExecuteSleepAsync(context, Scene(), "balanced", default);
Assert.Equal("set_hvac_mode", Assert.Single(gateway.Calls).Service);
Assert.Equal("Failed", Assert.Single(context.Results).Status);
}
[Fact]
public async Task AllOffClosesClimateButDoesNotMoveCurtain()
{
var gateway = new Gateway(ClimateState("heat"), CurtainState());
var runner = Runner(gateway);
var modules = new DeviceModuleRegistry([Climate(gateway), Curtain(gateway)]);
var coordinator = new HealthSceneCoordinator(modules, runner, gateway, new FakeLight(), new FakeMusic());
var output = await coordinator.ExecuteAsync("all-off", null, "", default);
Assert.Equal("Succeeded", output.Status);
Assert.Equal("off", gateway.States[ClimateId].State);
Assert.Equal(ClimateId, Assert.Single(gateway.Calls).EntityId);
Assert.Equal("turn_off", gateway.Calls[0].Service);
}
[Fact]
public async Task UnsupportedPowerFeaturesRejectManualPowerCommands()
{
var gateway = new Gateway(ClimateState(features: 1));
var module = Climate(gateway);
await Assert.ThrowsAsync<InvalidOperationException>(() => module.SetPowerAsync(true, default));
await Assert.ThrowsAsync<InvalidOperationException>(() => module.SetPowerAsync(false, default));
Assert.Empty(gateway.Calls);
}
[Theory]
[InlineData("open", "open_cover")]
[InlineData("stop", "stop_cover")]
[InlineData("close", "close_cover")]
public async Task CurtainSendsOneSupportedMotionCommandWithoutClaimingArrival(string action, string service)
{
var gateway = new Gateway(CurtainState());
var module = Curtain(gateway);
var command = module.BuildParameterCommand("curtain_action", action);
await module.ExecuteParameterAsync(command, default);
Assert.Null(command.ExpectedState);
Assert.Equal(service, Assert.Single(gateway.Calls).Service);
Assert.Equal(1, gateway.Reads);
}
[Fact]
public async Task CurtainPositionIsIntegerPercentAndRequiresPositionFeature()
{
var gateway = new Gateway(CurtainState());
var module = Curtain(gateway);
await module.ExecuteParameterAsync(module.BuildParameterCommand("curtain_position", 0), default);
await module.ExecuteParameterAsync(module.BuildParameterCommand("curtain_position", 100), default);
Assert.Equal(new[] { 0, 100 }, gateway.Calls.Select(command => (int)command.Data!["position"]!));
Assert.Throws<InvalidOperationException>(() => module.BuildParameterCommand("curtain_position", 100.5));
gateway.States[CurtainId] = CurtainState(features: 11);
await Assert.ThrowsAsync<InvalidOperationException>(() => module.ExecuteParameterAsync(
module.BuildParameterCommand("curtain_position", 50), default));
Assert.Equal(2, gateway.Calls.Count);
}
[Theory]
[InlineData("unavailable")]
[InlineData("unknown")]
public async Task OfflineCurtainRejectsCommandsAndCannotPretendOpen(string state)
{
var gateway = new Gateway(CurtainState(state));
var module = Curtain(gateway);
await Assert.ThrowsAsync<InvalidOperationException>(() => module.ExecuteParameterAsync(
module.BuildParameterCommand("curtain_action", "open"), default));
Assert.Empty(gateway.Calls);
}
[Fact]
public async Task CurtainStopIsDisabledWhenDeviceOnlySupportsOpenAndClose()
{
var gateway = new Gateway(CurtainState(features: 3));
var module = Curtain(gateway);
await Assert.ThrowsAsync<InvalidOperationException>(() => module.ExecuteParameterAsync(
module.BuildParameterCommand("curtain_action", "stop"), default));
Assert.Empty(gateway.Calls);
}
[Fact]
public async Task CurtainHasNoPowerAndNoAutomaticSceneMotion()
{
var gateway = new Gateway(CurtainState());
var module = Curtain(gateway);
await Assert.ThrowsAsync<InvalidOperationException>(() => module.SetPowerAsync(true, default));
var context = new DeviceSceneContext(Runner(gateway), gateway.States, []);
await module.ExecuteSleepAsync(context, Scene(), "balanced", default);
Assert.Equal("Skipped", Assert.Single(context.Results).Status);
Assert.Empty(module.GetShutdownCommands());
Assert.Empty(gateway.Calls);
}
[Fact]
public async Task UnconfiguredModulesRemainSkippedAndAbsentFromFallbackDevices()
{
var gateway = new Gateway();
var runner = Runner(gateway);
var options = Options.Create(new HomeAssistantOptions());
var climate = new ClimateModule(runner, options);
var curtain = new CurtainModule(runner, options);
var context = new DeviceSceneContext(runner, gateway.States, []);
await climate.ExecuteSleepAsync(context, Scene(), "balanced", default);
await curtain.ExecuteSleepAsync(context, Scene(), "balanced", default);
Assert.All(context.Results, result => Assert.Equal("Skipped", result.Status));
Assert.Empty(climate.GetShutdownCommands());
Assert.Empty(new HomeAssistantDeviceCatalog(options, new([climate, curtain])).BuildDiscoveredDevices(null, gateway.States));
Assert.Empty(gateway.Calls);
}
[Fact]
public void RegistryBindsEachDeviceAndSafeAttributesExposeActualCapabilities()
{
var gateway = new Gateway(ClimateState(), CurtainState("unavailable"));
var options = Options.Create(Settings());
var catalog = new HomeAssistantDeviceCatalog(options, new([Climate(gateway), Curtain(gateway)]));
var registry = new HomeAssistantRegistries
{
Devices = [new() { Id = "ac", Name = "空调" }, new() { Id = "curtain", Name = "窗帘" }],
Entities = [new() { EntityId = ClimateId, DeviceId = "ac" }, new() { EntityId = CurtainId, DeviceId = "curtain" }]
};
var devices = catalog.BuildDiscoveredDevices(registry, gateway.States);
Assert.Equal("climate", devices[0].ModuleKey);
Assert.Equal(ClimateId, devices[0].PrimaryControlEntityId);
Assert.Equal("curtain", devices[1].ModuleKey);
Assert.Equal(CurtainId, devices[1].PrimaryControlEntityId);
Assert.False(devices[1].Online);
Assert.Contains("target_temp_step", devices[0].Entities[0].Attributes!.Keys);
Assert.Contains("current_temperature", devices[0].Entities[0].Attributes!.Keys);
Assert.Contains("fan_modes", devices[0].Entities[0].Attributes!.Keys);
Assert.Contains("current_position", devices[1].Entities[0].Attributes!.Keys);
}
[Fact]
public async Task CancellationBeforeSendPreventsEveryNewDeviceWrite()
{
var gateway = new Gateway(ClimateState(), CurtainState());
using var cancellation = new CancellationTokenSource(); cancellation.Cancel();
var climate = Climate(gateway);
var curtain = Curtain(gateway);
await Assert.ThrowsAnyAsync<OperationCanceledException>(() => climate.ExecuteParameterAsync(
climate.BuildParameterCommand("climate_mode", "heat"), cancellation.Token));
await Assert.ThrowsAnyAsync<OperationCanceledException>(() => curtain.ExecuteParameterAsync(
curtain.BuildParameterCommand("curtain_action", "open"), cancellation.Token));
Assert.Empty(gateway.Calls);
}
private sealed class Gateway(params HomeAssistantState[] states) : IHomeAssistantGateway
{
public Dictionary<string, HomeAssistantState> States { get; } = states.ToDictionary(state => state.EntityId, StringComparer.OrdinalIgnoreCase);
public List<DeviceCommand> Calls { get; } = [];
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,
CancellationToken cancellationToken = default)
{
cancellationToken.ThrowIfCancellationRequested();
var entityId = (string)data["entity_id"]!;
Calls.Add(new(service, entityId, domain, service, new Dictionary<string, object?>(data)));
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"]!,
"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,
Attributes = JsonSerializer.SerializeToElement(attributes) };
return Task.CompletedTask;
}
}
}
@@ -0,0 +1,376 @@
using Evergreen.Health.Application.Health.Devices;
using Evergreen.Health.Application.Health.Devices.Audio;
using Evergreen.Health.Application.Health.Devices.Contracts;
using Evergreen.Health.Application.Health.Devices.Discovery;
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.Integrations.MiAirX;
using Evergreen.Health.Application.Health.Models;
using Evergreen.Health.Application.Health.Policies;
using Evergreen.Health.Application.Health.Scenes;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using System.Text.Json;
using Xunit;
namespace Admin.NET.Test.Health;
public sealed class DeviceArchitectureTests
{
private static DeviceActionRunner Runner(FakeHaGateway gateway)
=> new(gateway, TimeProvider.System, new DeviceExecutionOptions { VerificationDelay = TimeSpan.Zero });
private static HomeAssistantOptions Configuration() => new()
{
Entities = new()
{
AmbientLight = "light.bedroom", Speaker = "media_player.speaker", SpeakerSleepMode = "switch.sleep",
Temperature = "sensor.room_temperature"
}
};
private static HomeAssistantState Temperature(double value, DateTimeOffset? observed) => new()
{
EntityId = "sensor.room_temperature", State = value.ToString(System.Globalization.CultureInfo.InvariantCulture),
LastReported = observed, Attributes = JsonSerializer.SerializeToElement(new { unit_of_measurement = "°C" })
};
[Theory]
[InlineData(21.9, "heat")]
[InlineData(22.0, "off")]
[InlineData(23.5, "off")]
[InlineData(25.0, "off")]
[InlineData(25.1, "cool")]
public void RoomRangeIncludesBothBoundaries(double temperature, string expected)
{
var advice = RoomTemperaturePolicy.Evaluate(new(temperature, DateTimeOffset.UtcNow, true, ""), new(22, 25));
Assert.Equal(expected, advice.Mode);
Assert.Null(advice.TargetTemperatureCelsius);
RoomTemperaturePolicy.PickTargetForSession(advice);
if (expected is "heat" or "cool")
Assert.InRange(advice.TargetTemperatureCelsius!.Value, 22, 25);
else Assert.Null(advice.TargetTemperatureCelsius);
}
[Theory]
[InlineData(0)]
[InlineData(31)]
public void MissingOrStaleIndoorTimestampIsNotUsable(int ageMinutes)
{
var reader = new IndoorEnvironmentReader(Options.Create(Configuration()), TimeProvider.System);
var state = Temperature(10, ageMinutes == 0 ? null : DateTimeOffset.UtcNow.AddMinutes(-ageMinutes));
var sample = reader.Read(new Dictionary<string, HomeAssistantState> { [state.EntityId] = state });
Assert.False(sample.Available);
Assert.Equal("unavailable", RoomTemperaturePolicy.Evaluate(sample, new(22, 25)).Mode);
}
[Fact]
public void FreshIndoorDataAndConstitutionRangeAreUsed()
{
var reader = new IndoorEnvironmentReader(Options.Create(Configuration()), TimeProvider.System);
var state = Temperature(23, DateTimeOffset.UtcNow);
var sample = reader.Read(new Dictionary<string, HomeAssistantState> { [state.EntityId] = state });
Assert.True(sample.Available);
Assert.Equal("heat", RoomTemperaturePolicy.Evaluate(sample, RecommendedRoomRange.ForConstitution("yang_deficient")).Mode);
Assert.Equal("off", RoomTemperaturePolicy.Evaluate(sample, RecommendedRoomRange.ForConstitution("balanced")).Mode);
}
[Fact]
public async Task NewDeviceWorksThroughRegistryAndSceneWithoutCoreBranches()
{
var gateway = new FakeHaGateway();
gateway.States["number.synthetic"] = new() { EntityId = "number.synthetic", State = "0" };
var runner = Runner(gateway);
var module = new SyntheticDeviceModule(runner);
var registry = new DeviceModuleRegistry([module]);
Assert.Same(module, registry.ForParameter("synthetic_value"));
await registry.ForParameter("synthetic_value").ExecuteParameterAsync(module.BuildParameterCommand("synthetic_value", 4), default);
var coordinator = new HealthSceneCoordinator(registry, runner, gateway, new FakeLight(), new FakeMusic());
var output = await coordinator.ExecuteAsync("sleep", new(), "balanced", default);
Assert.Equal("Succeeded", output.Status);
Assert.Contains(gateway.Calls, command => command.EntityId == "number.synthetic" && (int)command.Data!["value"]! == 3);
}
[Fact]
public void DuplicateParameterRegistrationsFailEarly()
{
var runner = Runner(new());
Assert.Throws<InvalidOperationException>(() => new DeviceModuleRegistry(
[new SyntheticDeviceModule(runner), new SyntheticDeviceModule(runner, key: "other")]));
}
[Fact]
public void DeviceParametersValidateRangesAndTrustedEntity()
{
var runner = Runner(new());
var light = new LightingModule(runner, Options.Create(Configuration()), new FakeLight(), new LightOffDispatcher([]));
Assert.Throws<InvalidOperationException>(() => light.BuildParameterCommand("color_temp", 6500));
Assert.Equal("light.bedroom", light.BuildParameterCommand("brightness", 0).EntityId);
Assert.Equal("turn_off", light.BuildParameterCommand("brightness", 0).Service);
}
[Fact]
public void DiscoveryAcceptsOtherBrandsAndFiltersDisabledEntities()
{
var gateway = new FakeHaGateway();
var registry = new DeviceModuleRegistry([]);
var catalog = new HomeAssistantDeviceCatalog(Options.Create(Configuration()), registry);
var haRegistry = new HomeAssistantRegistries
{
Devices = [new() { Id = "tuya-device", Name = "窗帘", Manufacturer = "Tuya" }],
Entities =
[
new() { DeviceId = "tuya-device", EntityId = "cover.curtain", Platform = "tuya" },
new() { DeviceId = "tuya-device", EntityId = "switch.disabled", DisabledBy = "user" }
]
};
var devices = catalog.BuildDiscoveredDevices(haRegistry, new Dictionary<string, HomeAssistantState>
{
["cover.curtain"] = new() { EntityId = "cover.curtain", State = "open" }
});
Assert.Single(devices);
Assert.Equal("Curtain", devices[0].Category);
Assert.True(devices[0].Online);
Assert.Single(devices[0].Entities);
}
[Fact]
public void FallbackOnlyContainsConfiguredModules()
{
var runner = Runner(new());
var options = Options.Create(Configuration());
var catalog = new HomeAssistantDeviceCatalog(options, new DeviceModuleRegistry([new LightingModule(runner, options, new FakeLight(), new LightOffDispatcher([]))]));
var device = Assert.Single(catalog.BuildDiscoveredDevices(null, new Dictionary<string, HomeAssistantState>()));
Assert.Equal("lighting", device.ModuleKey);
Assert.Contains(device.Entities, entity => entity.EntityId == "light.bedroom");
Assert.Equal("light.bedroom", device.PrimaryControlEntityId);
}
[Fact]
public async Task Configured_inventory_filters_retired_devices_even_if_ha_cache_still_reports_them()
{
var services = new ServiceCollection();
services.AddLogging(); services.AddOptions(); services.AddHttpClient(); services.AddHealthDevices();
var gateway = new FakeHaGateway { ReadsToFail = 1 };
services.AddSingleton<IHomeAssistantGateway>(gateway);
services.AddSingleton(Options.Create(new HomeAssistantOptions
{
OnlyConfiguredDevices = true,
Entities = new()
{
Climate = "climate.keep", Temperature = "sensor.keep_temperature", Curtain = "cover.keep",
Dehumidifier = "humidifier.keep", FragranceSwitch = "switch.keep_fragrance", Speaker = "media_player.keep"
},
DisplayOnlyDevices = [new() { EntityId = "switch.wake_fragrance", Name = "香氛机 唤醒", Category = "Fragrance" }]
}));
using var provider = services.BuildServiceProvider();
var entities = new[] { "climate.keep", "sensor.keep_temperature", "cover.keep", "humidifier.keep",
"switch.keep_fragrance", "media_player.keep", "switch.wake_fragrance", "light.removed", "switch.removed_outlet", "sensor.removed_weight" };
var registry = new HomeAssistantRegistries
{
Devices = entities.Select(id => new HomeAssistantDeviceRegistryItem { Id = id, Name = id }).ToList(),
Entities = entities.Select(id => new HomeAssistantEntityRegistryItem { DeviceId = id, EntityId = id }).ToList()
};
var state = entities.ToDictionary(id => id, id => new HomeAssistantState { EntityId = id, State = "on" });
var catalog = provider.GetRequiredService<HomeAssistantDeviceCatalog>();
foreach (var inventory in new[] { catalog.BuildDiscoveredDevices(registry, state), catalog.BuildDiscoveredDevices(null, state) })
{
Assert.Equal(7, inventory.Count);
Assert.DoesNotContain(inventory.SelectMany(device => device.Entities), entity => entity.EntityId.Contains("removed"));
var wake = Assert.Single(inventory, device => device.Entities.Any(entity => entity.EntityId == "switch.wake_fragrance"));
Assert.Null(wake.PrimaryControlEntityId);
Assert.True(wake.Online);
var sleep = Assert.Single(inventory, device => device.Entities.Any(entity => entity.EntityId == "switch.keep_fragrance"));
Assert.Equal("switch.keep_fragrance", sleep.PrimaryControlEntityId);
}
var modules = provider.GetRequiredService<DeviceModuleRegistry>();
Assert.DoesNotContain(modules.Modules, module => module.Key is "lighting" or "outlet");
Assert.Throws<InvalidOperationException>(() => modules.ForParameter("brightness"));
var lights = provider.GetRequiredService<HomeAssistantLightTransitionService>();
await Assert.ThrowsAsync<InvalidOperationException>(() => lights.StartSleepAsync(2700, default));
Assert.Equal(1, gateway.ReadsToFail);
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()
{
var gateway = new FakeHaGateway { FailEntity = "switch.bad" };
var runner = Runner(gateway);
var coordinator = new HealthSceneCoordinator(new([new SyntheticDeviceModule(runner, twoOff: true)]),
runner, gateway, new FakeLight(), new FakeMusic());
var output = await coordinator.ExecuteAsync("all-off", null, "", default);
Assert.Equal("Partial", output.Status);
Assert.Contains(output.Actions, action => action.EntityId == "switch.bad" && action.Status == "Failed");
Assert.Contains(output.Actions, action => action.EntityId == "switch.synthetic" && action.Status == "Succeeded");
}
[Fact]
public async Task ShutdownStillSendsWhenInitialStateReadFails()
{
var gateway = new FakeHaGateway { ReadsToFail = 1 };
var runner = Runner(gateway);
var music = new FakeMusic();
var coordinator = new HealthSceneCoordinator(new([new SyntheticDeviceModule(runner)]),
runner, gateway, new FakeLight(), music);
var output = await coordinator.ExecuteAsync("all-off", null, "", default);
Assert.True(music.StopCount > 0);
Assert.Contains(gateway.Calls, command => command.EntityId == "switch.synthetic" && command.Service == "turn_off");
Assert.Contains(output.Actions, action => action.EntityId == "switch.synthetic" && action.Status == "Succeeded");
}
[Fact]
public async Task AllOffCancelsOldStartBeforeItCanSendDelayedCommands()
{
var gateway = new FakeHaGateway();
var runner = Runner(gateway);
var module = new SyntheticDeviceModule(runner, block: true);
var coordinator = new HealthSceneCoordinator(new([module]), runner, gateway, new FakeLight(), new FakeMusic());
var start = coordinator.ExecuteAsync("sleep", new(), "balanced", default);
await module.Started.Task.WaitAsync(TimeSpan.FromSeconds(3));
var off = await coordinator.ExecuteAsync("all-off", null, "", default);
Assert.Equal("Cancelled", (await start).Status);
Assert.False(module.ContinuedAfterWait);
Assert.Equal("Succeeded", off.Status);
Assert.DoesNotContain(gateway.Calls, command => command.Service == "set_value");
}
[Fact]
public async Task ManualLightOffOnlyControlsTheLight()
{
var gateway = new FakeHaGateway();
var runner = Runner(gateway);
var options = Options.Create(Configuration());
var light = new LightingModule(runner, options, new FakeLight(), new LightOffDispatcher([]));
await light.ExecuteParameterAsync(light.BuildParameterCommand("brightness", 0), default);
Assert.Equal("off", gateway.States["light.bedroom"].State);
Assert.All(gateway.Calls, command => Assert.Equal("light.bedroom", command.EntityId));
}
[Fact]
public void UnknownButtonIsUsableButUnknownSensorIsNot()
{
Assert.True(DeviceActionRunner.IsAvailable(new() { EntityId = "button.release", State = "unknown" }));
Assert.False(DeviceActionRunner.IsAvailable(new() { EntityId = "sensor.temperature", State = "unknown" }));
Assert.Null(new HomeAssistantState().GetDoubleAttribute("brightness"));
}
[Fact]
public void ModuleRegistrationResolvesWithoutCircularOrScopedDependencies()
{
var services = new ServiceCollection();
services.AddLogging(); services.AddOptions(); services.AddHttpClient();
services.AddHealthDevices();
using var provider = services.BuildServiceProvider(new ServiceProviderOptions { ValidateOnBuild = true, ValidateScopes = true });
Assert.NotNull(provider.GetRequiredService<HealthSceneCoordinator>());
Assert.Equal(4, provider.GetServices<IHostedService>().Count());
Assert.DoesNotContain(provider.GetRequiredService<DeviceModuleRegistry>().Modules, module => module.Key == "mattress");
Assert.Empty(provider.GetServices<ILightOffHandler>());
Assert.Throws<InvalidOperationException>(() => provider.GetRequiredService<DeviceModuleRegistry>().ForParameter("mattress_left_heating"));
}
[Fact]
public async Task ShortPlaylistInheritsFadeVolumeAndStopsAtZero()
{
var time = new ManualTestTimeProvider();
var audio = new FakeAudioGateway();
var options = Options.Create(new TcmMusicOptions
{
StartVolume = 70, FadeStepSeconds = 1, // 本版固定规则不能被历史设置覆盖。
Tracks = new() { ["balanced"] =
[
new() { Title = "A", Url = "https://example.invalid/a.mp3", DurationSeconds = 20 },
new() { Title = "B", Url = "https://example.invalid/b.mp3", DurationSeconds = 20 }
] }
});
var music = new TcmMusicTransitionService(audio, options, NullLogger<TcmMusicTransitionService>.Instance, time);
await music.StartForConstitutionAsync("balanced", default);
Assert.Equal(50, music.GetStatus().VolumePercent);
for (var step = 1; step <= 5; step++)
{
time.Advance(TimeSpan.FromSeconds(5));
var expected = 50 - step;
await WaitUntilAsync(() => music.GetStatus().VolumePercent == expected);
}
Assert.Equal(2, audio.Tracks.Count);
Assert.Equal(new[] { 50, 49, 48, 47, 46, 45 }, audio.Volumes.ToArray());
time.Advance(TimeSpan.FromSeconds(225));
await WaitUntilAsync(() => !music.GetStatus().Running);
Assert.Equal(0, music.GetStatus().VolumePercent);
Assert.True(audio.Stops > 0);
await music.StopMusicAsync(default);
}
[Fact]
public async Task ManualVolumeTakesOwnershipAndEveryRestartBeginsAtFifty()
{
var time = new ManualTestTimeProvider();
var audio = new FakeAudioGateway();
var music = new TcmMusicTransitionService(audio, Options.Create(new TcmMusicOptions
{
Tracks = new() { ["balanced"] = [new() { Url = "https://example.invalid/a.mp3", DurationSeconds = 20 }] }
}), NullLogger<TcmMusicTransitionService>.Instance, time);
await music.StartForConstitutionAsync("balanced", default);
await music.SetManualVolumeAsync(30, default);
time.Advance(TimeSpan.FromSeconds(50));
Assert.False(music.GetStatus().Running);
Assert.Equal(30, music.GetStatus().VolumePercent);
await music.StartForConstitutionAsync("balanced", default);
Assert.Equal(50, music.GetStatus().VolumePercent);
await music.StopMusicAsync(default);
}
private static async Task WaitUntilAsync(Func<bool> predicate)
{
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(3));
while (!predicate()) await Task.Delay(5, timeout.Token);
}
}
+170
View File
@@ -0,0 +1,170 @@
using Evergreen.Health.Application.Health.Devices.Contracts;
using Evergreen.Health.Application.Health.Devices;
using Evergreen.Health.Application.Health.Integrations.HomeAssistant;
using Evergreen.Health.Application.Health.Integrations.MiAirX;
using Evergreen.Health.Application.Health.Models;
using Evergreen.Health.Application.Health.Scenes;
using System.Collections.Concurrent;
using System.Text.Json;
namespace Admin.NET.Test.Health;
internal sealed class FakeHaGateway : IHomeAssistantGateway
{
public ConcurrentDictionary<string, HomeAssistantState> States { get; } = new(StringComparer.OrdinalIgnoreCase);
public ConcurrentQueue<DeviceCommand> Calls { get; } = new();
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)
{
cancellationToken.ThrowIfCancellationRequested();
if (ReadsToFail > 0) { ReadsToFail--; throw new IOException("模拟状态读取失败"); }
return Task.FromResult<IReadOnlyList<HomeAssistantState>>(States.Values.ToArray());
}
public Task CallServiceAsync(string domain, string service, IReadOnlyDictionary<string, object?> data,
CancellationToken cancellationToken = default)
{
cancellationToken.ThrowIfCancellationRequested();
var entity = (string)data["entity_id"]!;
Calls.Enqueue(new(service, entity, domain, service, new Dictionary<string, object?>(data)));
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()!,
"set_value" => data["value"]!.ToString()!, _ => States.GetValueOrDefault(entity)?.State ?? "unknown"
};
if (service == "set_hvac_mode") state = (string)data["hvac_mode"]!;
var attributes = new Dictionary<string, object?>();
if (States.TryGetValue(entity, out var previous) && previous.Attributes.ValueKind == JsonValueKind.Object)
foreach (var property in previous.Attributes.EnumerateObject()) attributes[property.Name] = property.Value.Clone();
if (data.TryGetValue("brightness_pct", out var brightness))
attributes["brightness"] = Convert.ToDouble(brightness) / 100 * 255;
foreach (var key in new[] { "effect", "color_temp_kelvin", "temperature", "fan_mode" })
if (data.TryGetValue(key, out var value)) attributes[key] = value;
States[entity] = new() { EntityId = entity, State = state, LastReported = Time.GetUtcNow(), LastUpdated = Time.GetUtcNow(),
Attributes = JsonSerializer.SerializeToElement(attributes) };
return Task.CompletedTask;
}
}
internal sealed class FakeMusic : ITcmMusicRuntime
{
public int StopCount { get; private set; }
public TcmMusicTransitionOutput GetStatus() => new();
public bool HasPlayableTracks(string code) => true;
public Task StartForConstitutionAsync(string code, CancellationToken ct) => Task.CompletedTask;
public Task PlayTestMusicAsync(CancellationToken ct) => Task.CompletedTask;
public Task SetManualVolumeAsync(int volume, CancellationToken ct) => Task.CompletedTask;
public Task StopMusicAsync(CancellationToken ct) { StopCount++; return Task.CompletedTask; }
}
internal sealed class FakeLight : ILightTransitionRuntime
{
public HomeAssistantLightTransitionOutput GetStatus() => new();
public Task<HomeAssistantLightTransitionOutput> StartSleepAsync(int kelvin, CancellationToken ct) => Task.FromResult(new HomeAssistantLightTransitionOutput());
public Task<HomeAssistantLightTransitionOutput> StartWakeAsync(CancellationToken ct) => Task.FromResult(new HomeAssistantLightTransitionOutput());
public Task<HomeAssistantLightTransitionOutput> CancelAsync(CancellationToken ct) => Task.FromResult(new HomeAssistantLightTransitionOutput());
}
internal sealed class SyntheticDeviceModule : HealthDeviceModule
{
private readonly bool _block;
private readonly bool _twoOff;
private readonly string _key;
public TaskCompletionSource Started { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously);
public bool ContinuedAfterWait { get; private set; }
public SyntheticDeviceModule(DeviceActionRunner runner, bool block = false, bool twoOff = false, string key = "synthetic")
: base(runner) => (_block, _twoOff, _key) = (block, twoOff, key);
public override string Key => _key;
public override string Category => nameof(HealthDeviceCategory.Other);
public override string DisplayName => "测试新增家具";
public override string PrimaryEntityId => "switch.synthetic";
public override IReadOnlyList<DeviceParameterDefinition> Parameters =>
[DeviceValues.Number("synthetic_value", "number.synthetic", 0, 5, "测试参数")];
public override async Task ExecuteSleepAsync(DeviceSceneContext context, PersonalizedSceneConfig config,
string constitutionCode, CancellationToken ct)
{
Started.TrySetResult();
if (_block) await Task.Delay(Timeout.InfiniteTimeSpan, ct);
ContinuedAfterWait = true;
await context.RunAsync(BuildParameterCommand("synthetic_value", 3), ct);
}
public override IEnumerable<DeviceCommand> GetShutdownCommands()
{
if (_twoOff) yield return new("失败设备关闭", "switch.bad", "switch", "turn_off", ExpectedState: "off");
yield return new("测试家具关闭", PrimaryEntityId, "switch", "turn_off", ExpectedState: "off");
}
}
internal sealed class FakeAudioGateway : IAudioPlaybackGateway
{
public bool IsConfigured => true;
public ConcurrentQueue<int> Volumes { get; } = new();
public ConcurrentQueue<string> Tracks { get; } = new();
public int Stops;
public Task PlayAsync(CancellationToken ct = default) => Task.CompletedTask;
public Task PlayUrlAsync(string url, CancellationToken ct = default) { Tracks.Enqueue(url); return Task.CompletedTask; }
public Task StopAsync(CancellationToken ct = default) { Interlocked.Increment(ref Stops); return Task.CompletedTask; }
public Task SetVolumeAsync(int volume, CancellationToken ct = default) { Volumes.Enqueue(volume); return Task.CompletedTask; }
}
/// <summary>只推进测试时间,不等待真实 250 秒,也不依赖额外测试包。</summary>
internal sealed class ManualTestTimeProvider : TimeProvider
{
private readonly object _lock = new();
private readonly List<ManualTimer> _timers = new();
private readonly DateTimeOffset _origin = DateTimeOffset.UtcNow;
private long _ticks;
public override long TimestampFrequency => TimeSpan.TicksPerSecond;
public override long GetTimestamp() { lock (_lock) return _ticks; }
public override DateTimeOffset GetUtcNow() { lock (_lock) return _origin.AddTicks(_ticks); }
public override ITimer CreateTimer(TimerCallback callback, object? state, TimeSpan dueTime, TimeSpan period)
{
var timer = new ManualTimer(this, callback, state);
lock (_lock) { _timers.Add(timer); timer.Change(dueTime, period); }
return timer;
}
public void Advance(TimeSpan duration)
{
List<ManualTimer> due;
lock (_lock)
{
_ticks += duration.Ticks;
due = _timers.Where(timer => !timer.Disposed && timer.Due <= _ticks).ToList();
foreach (var timer in due) timer.Due = timer.Period > 0 ? _ticks + timer.Period : long.MaxValue;
}
foreach (var timer in due) timer.Callback(timer.State);
}
private sealed class ManualTimer : ITimer
{
private readonly ManualTestTimeProvider _owner;
public TimerCallback Callback { get; }
public object? State { get; }
public bool Disposed;
public long Due;
public long Period;
public ManualTimer(ManualTestTimeProvider owner, TimerCallback callback, object? state)
=> (_owner, Callback, State) = (owner, callback, state);
public bool Change(TimeSpan dueTime, TimeSpan period)
{
lock (_owner._lock)
{
if (Disposed) return false;
Due = dueTime == Timeout.InfiniteTimeSpan ? long.MaxValue : _owner._ticks + dueTime.Ticks;
Period = period == Timeout.InfiniteTimeSpan ? 0 : period.Ticks;
return true;
}
}
public void Dispose() { lock (_owner._lock) { Disposed = true; _owner._timers.Remove(this); } }
public ValueTask DisposeAsync() { Dispose(); return ValueTask.CompletedTask; }
}
}
@@ -0,0 +1,148 @@
using Evergreen.Health.Application.Health.Devices;
using Evergreen.Health.Application.Health.Devices.Climate;
using Evergreen.Health.Application.Health.Devices.Discovery;
using Evergreen.Health.Application.Health.Integrations.HomeAssistant;
using Evergreen.Health.Application.Health.Models;
using Evergreen.Health.Application.Health.Scenes;
using Microsoft.Extensions.Options;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using Newtonsoft.Json.Serialization;
using System.Text.Json;
using Xunit;
using SystemJson = System.Text.Json.JsonSerializer;
namespace Admin.NET.Test.Health;
/// <summary>检查 HA JSON 到 API JSON 的完整转换,不连接 HA、不发送家具指令。</summary>
public sealed class HomeAssistantAttributeSerializationTests
{
private const string ClimateId = "climate.test_room";
private static HomeAssistantState State(string state = "off") => new()
{
EntityId = ClimateId,
State = state,
Attributes = SystemJson.SerializeToElement(new
{
hvac_modes = new[] { "cool", "heat", "fan_only", "off" },
fan_modes = new[] { "自动", "低风", "中风", "高风" },
swing_modes = new[] { "off", "vertical" },
options = new[] { "智能适配", "轻柔", "全身" },
supported_color_modes = new[] { "color_temp" },
effect_list = new[] { "阅读模式", "助眠模式" },
current_temperature = 24.9,
temperature = 23,
temperature_unit = "°C",
min_temp = 5,
max_temp = 35,
target_temp_step = 0.5,
supported_features = 393,
fan_mode = "低风"
})
};
private static string SerializeApi(object value) => JsonConvert.SerializeObject(value, new JsonSerializerSettings
{
ContractResolver = new CamelCasePropertyNamesContractResolver(),
DateFormatString = "yyyy-MM-dd HH:mm:ss"
});
[Theory]
[InlineData("hvac_modes")]
[InlineData("fan_modes")]
[InlineData("swing_modes")]
[InlineData("options")]
[InlineData("supported_color_modes")]
[InlineData("effect_list")]
public void ApiSerializesChoiceArraysAsStringsNotJsonElementMetadata(string key)
{
var state = State();
var safe = HomeAssistantDeviceCatalog.ExtractSafeAttributes(state)!;
var json = JObject.Parse(SerializeApi(safe));
var values = Assert.IsType<JArray>(json[key]);
Assert.All(values, value => Assert.Equal(JTokenType.String, value.Type));
Assert.Equal(state.Attributes.GetProperty(key).EnumerateArray().Select(value => value.GetString()),
values.Select(value => value.Value<string>()));
}
[Theory]
[InlineData(false)]
[InlineData(true)]
public void DiscoveredClimateAndParameterReadingsKeepCapabilitiesAcrossApiSerialization(bool useSystemJson)
{
var state = State();
var states = new Dictionary<string, HomeAssistantState> { [ClimateId] = state };
var options = Options.Create(new HomeAssistantOptions { Entities = new() { Climate = ClimateId } });
var module = new ClimateModule(new DeviceActionRunner(new NoWritesGateway(), TimeProvider.System, new()), options);
var catalog = new HomeAssistantDeviceCatalog(options, new DeviceModuleRegistry([module]));
var devices = catalog.BuildDiscoveredDevices(new HomeAssistantRegistries
{
Devices = [new() { Id = "ac", Name = "空调" }],
Entities = [new() { EntityId = ClimateId, DeviceId = "ac" }]
}, states);
var status = new HomeAssistantStatusOutput
{
Connected = true,
Devices = devices,
ControlParameters = module.Parameters.ToDictionary(parameter => parameter.Key, parameter =>
new HomeAssistantParameterStateOutput
{
EntityId = parameter.EntityId,
State = state.State,
Available = true,
Attributes = HomeAssistantDeviceCatalog.ExtractSafeAttributes(state)
})
};
var wire = JObject.Parse(useSystemJson
? SystemJson.Serialize(status, new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase })
: SerializeApi(status));
var device = wire["devices"]![0]!;
Assert.Equal("climate", device["moduleKey"]!.Value<string>());
Assert.True(device["online"]!.Value<bool>());
Assert.Equal(ClimateId, device["primaryControlEntityId"]!.Value<string>());
var entity = device["entities"]![0]!;
Assert.True(entity["available"]!.Value<bool>());
Assert.Equal("off", entity["state"]!.Value<string>()); // 关闭不等于不可用。
foreach (var reading in new[] { entity, wire["controlParameters"]!["climate_mode"]!,
wire["controlParameters"]!["climate_temperature"]!, wire["controlParameters"]!["climate_fan_mode"]! })
{
var attributes = reading["attributes"]!;
Assert.Equal(new[] { "cool", "heat", "fan_only", "off" }, attributes["hvac_modes"]!.Values<string>());
Assert.Equal(new[] { "自动", "低风", "中风", "高风" }, attributes["fan_modes"]!.Values<string>());
Assert.Equal(24.9, attributes["current_temperature"]!.Value<double>());
Assert.Equal(23, attributes["temperature"]!.Value<int>());
Assert.Equal("°C", attributes["temperature_unit"]!.Value<string>());
Assert.Equal(0.5, attributes["target_temp_step"]!.Value<double>());
Assert.Equal(393, attributes["supported_features"]!.Value<int>());
}
}
[Theory]
[InlineData(false)]
[InlineData(true)]
public void ConversionPreservesJsonTypesAndDoesNotExposeUnapprovedRootAttributes(bool useSystemJson)
{
using var document = JsonDocument.Parse("""
{"options":["off",null,true,12,0.5,["nested"],{"choice":"cool","flag":false}],
"temperature":null,"is_volume_muted":false,"volume_level":0.5,
"access_token":"private","unapproved_attribute":{"secret":"private"}}
""");
var state = new HomeAssistantState { Attributes = document.RootElement };
var safe = HomeAssistantDeviceCatalog.ExtractSafeAttributes(state)!;
var wire = JObject.Parse(useSystemJson ? SystemJson.Serialize(safe) : SerializeApi(safe));
var expected = JObject.Parse("""
{"options":["off",null,true,12,0.5,["nested"],{"choice":"cool","flag":false}],
"temperature":null,"is_volume_muted":false,"volume_level":0.5}
""");
Assert.True(JToken.DeepEquals(expected, wire), wire.ToString());
}
private sealed class NoWritesGateway : IHomeAssistantGateway
{
public Task<IReadOnlyList<HomeAssistantState>> GetStatesAsync(CancellationToken cancellationToken = default)
=> throw new InvalidOperationException("Serialization tests must not read a device");
public Task CallServiceAsync(string domain, string service, IReadOnlyDictionary<string, object?> data,
CancellationToken cancellationToken = default)
=> throw new InvalidOperationException("Serialization tests must not control a device");
}
}
@@ -0,0 +1,72 @@
using Evergreen.Health.Application.Health.Integrations.HomeAssistant;
using Microsoft.Extensions.Options;
using System.Net;
using System.Text;
using Xunit;
namespace Admin.NET.Test.Health;
public sealed class HomeAssistantTemperatureUnitTests
{
[Theory]
[InlineData("°C")]
[InlineData("°F")]
public async Task NativeClimateWithoutUnitUsesHaUnitSystemWithoutGuessing(string unit)
{
var handler = new UnitHandler("""[{"entity_id":"climate.room","state":"off","attributes":{"temperature":23,"min_temp":5,"max_temp":35,"target_temp_step":0.5,"supported_features":393}}]""",
unit);
var states = await Client(handler).GetStatesAsync();
var state = Assert.Single(states);
Assert.Equal(unit, state.GetStringAttribute("temperature_unit"));
Assert.Equal(23d, state.GetDoubleAttribute("temperature"));
Assert.Equal(393d, state.GetDoubleAttribute("supported_features"));
Assert.Equal(new[] { "/api/states", "/api/config" }, handler.Paths);
}
[Fact]
public async Task ExplicitDeviceUnitIsNotOverwritten()
{
var handler = new UnitHandler("""[{"entity_id":"climate.room","state":"off","attributes":{"temperature_unit":"°F","temperature":73}}]""", "°C");
var state = Assert.Single(await Client(handler).GetStatesAsync());
Assert.Equal("°F", state.GetStringAttribute("temperature_unit"));
Assert.Equal(new[] { "/api/states" }, handler.Paths);
}
[Fact]
public async Task MissingConfigKeepsUnitUnknownAndDoesNotHideOtherDevices()
{
var handler = new UnitHandler("""[{"entity_id":"climate.room","state":"off","attributes":{"temperature":23}},{"entity_id":"switch.room","state":"on","attributes":{}}]""",
"°C", HttpStatusCode.ServiceUnavailable);
var states = await Client(handler).GetStatesAsync();
Assert.Equal(2, states.Count);
Assert.Null(states[0].GetStringAttribute("temperature_unit"));
Assert.Equal("on", states[1].State);
Assert.Equal(23d, states[0].GetDoubleAttribute("temperature"));
}
[Fact]
public async Task OtherDevicesDoNotRequireUnitProbe()
{
var handler = new UnitHandler("""[{"entity_id":"cover.room","state":"unavailable","attributes":{"supported_features":15}}]""", "°C");
Assert.Single(await Client(handler).GetStatesAsync());
Assert.Equal(new[] { "/api/states" }, handler.Paths);
}
private static HomeAssistantClient Client(UnitHandler handler) => new(new HttpClient(handler),
Options.Create(new HomeAssistantOptions { BaseUrl = "https://ha.example.invalid", AccessToken = "fake-test-token" }));
private sealed class UnitHandler(string states, string unit, HttpStatusCode configStatus = HttpStatusCode.OK) : HttpMessageHandler
{
public List<string> Paths { get; } = [];
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
cancellationToken.ThrowIfCancellationRequested();
Assert.Equal(HttpMethod.Get, request.Method);
var path = request.RequestUri!.AbsolutePath;
Paths.Add(path);
var content = path == "/api/states" ? states : System.Text.Json.JsonSerializer.Serialize(new { unit_system = new { temperature = unit } });
return Task.FromResult(new HttpResponseMessage(path == "/api/states" ? HttpStatusCode.OK : configStatus)
{ Content = new StringContent(content, Encoding.UTF8, "application/json") });
}
}
}
@@ -0,0 +1,48 @@
using Evergreen.Health.Application.Health.Integrations.Ib43;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
using Xunit;
namespace Admin.NET.Test.Health;
[CollectionDefinition("Ib43Environment", DisableParallelization = true)]
public sealed class Ib43EnvironmentCollection { }
[Collection("Ib43Environment")]
public sealed class Ib43ConfigurationTests
{
[Fact]
public void Environment_overrides_defaults_after_binding_and_before_validation()
{
var names = new[] { "Ib43__Enabled", "Ib43__Password" };
var original = names.Select(Environment.GetEnvironmentVariable).ToArray();
try
{
Environment.SetEnvironmentVariable(names[0], "true");
Environment.SetEnvironmentVariable(names[1], "test-only-secret");
var services = new ServiceCollection();
services.AddOptions<Ib43Options>().Configure(options =>
{
options.Enabled = false; options.Password = "";
options.Devices = [new() { ProductKey = "test", SerialNumber = "sim" }];
}).PostConfigure(Ib43Configuration.ApplyEnvironmentOverrides)
.Validate(options => options.IsValid());
using var provider = services.BuildServiceProvider();
var options = provider.GetRequiredService<IOptions<Ib43Options>>().Value;
Assert.True(options.Enabled);
Assert.Equal("test-only-secret", options.Password);
Assert.Single(options.Devices);
Environment.SetEnvironmentVariable(names[0], null);
Environment.SetEnvironmentVariable(names[1], null);
var defaults = new Ib43Options();
Ib43Configuration.ApplyEnvironmentOverrides(defaults);
Assert.False(defaults.Enabled);
Assert.Empty(defaults.Password);
}
finally
{
for (var i = 0; i < names.Length; i++) Environment.SetEnvironmentVariable(names[i], original[i]);
}
}
}
@@ -0,0 +1,384 @@
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;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using MQTTnet;
using MQTTnet.Client;
using MQTTnet.Protocol;
using MQTTnet.Server;
using System.Net;
using System.Net.Sockets;
using System.Collections.Concurrent;
using System.Text.Json.Nodes;
using System.Text.Json;
using Xunit;
namespace Admin.NET.Test.Health;
public sealed class Ib43MqttIntegrationTests
{
private const string LeftBinding = "a98788f2aeeb40b68f145a182d99f832";
private const string TargetCurtain = "cover.test_curtain";
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_resubscribes_and_only_fresh_empty_to_occupied_triggers_after_broker_restart()
{
// 只监听 loopback,随机端口;不访问用户的 EMQX、数据库或家具。
using var reservation = new TcpListener(IPAddress.Loopback, 0);
reservation.Start();
var port = ((IPEndPoint)reservation.LocalEndpoint).Port;
reservation.Stop();
var factory = new MqttFactory();
using var broker = factory.CreateMqttServer(new MqttServerOptionsBuilder()
.WithDefaultEndpoint().WithDefaultEndpointPort(port)
.WithDefaultEndpointBoundIPAddress(IPAddress.Loopback).Build());
await broker.StartAsync();
var binding = new Ib43Binding { ProductKey = "evergreen-test", SerialNumber = "IB43SIM001" };
var options = Options.Create(new Ib43Options { Enabled = true, Host = "127.0.0.1", Port = port,
Username = "local-test", Password = "local-test-only", Devices = [binding] });
var store = new Ib43StateStore(options, new(), TimeProvider.System);
using var sink = new DatabaseSink(binding);
var gateway = new FakeHaGateway { CoverCloseResult = "closed" };
var openCurtain = new HomeAssistantState { EntityId = TargetCurtain, State = "open",
Attributes = JsonSerializer.SerializeToElement(new { supported_features = 2 }) };
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.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, observedPresence);
using var publisher = factory.CreateMqttClient();
try
{
await automation.StartAsync(default);
await service.StartAsync(default);
await WaitFor(() => store.Snapshot()[0].Transport == "connected");
await publisher.ConnectAsync(new MqttClientOptionsBuilder().WithTcpServer("127.0.0.1", port)
.WithClientId("ib43-local-simulator").Build());
foreach (var name in new[] { "startup.pdf-example.json", "realtime.pdf-example.json", "sleep-event.pdf-keys.json" })
{
var json = JsonNode.Parse(File.ReadAllText(Path.Combine(AppContext.BaseDirectory, "Ib43Examples", name)));
json["params"]["deviceName"] = binding.SerialNumber;
if (json["params"]["productKey"] != null) json["params"]["productKey"] = binding.ProductKey;
await publisher.PublishAsync(new MqttApplicationMessageBuilder().WithTopic(binding.PostTopic)
.WithPayload(json.ToJsonString()).WithQualityOfServiceLevel(MqttQualityOfServiceLevel.AtLeastOnce).Build());
}
await WaitFor(() => store.Snapshot()[0].Readings.TryGetValue("left.heartRate", out var value) && value.Value == 74);
Assert.Equal(80d, store.Snapshot()[0].Readings["right.heartRate"].Value);
Assert.Equal(60, store.Snapshot()[0].Fields["characteristic"].Value.GetProperty("reportIerVal").GetInt32());
Assert.All(store.Diagnostics(), entry => Assert.Empty(entry.Issues));
await WaitFor(() => Volatile.Read(ref sink.SavedReports) == 2);
Assert.Equal(74d, (await sink.Repository.Latest(1, LeftBinding))["heartRate"].Value);
Assert.Equal(80d, (await sink.Repository.Latest(2, "right-test"))["heartRate"].Value);
Assert.Empty(gateway.Calls);
await PublishPresence(publisher, binding, 0, 0);
await PublishPresence(publisher, binding, 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) == 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) == 20);
Assert.Equal(75d, (await sink.Repository.Latest(1, LeftBinding))["heartRate"].Value);
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;
public readonly Ib43DataRepository Repository;
public int SavedReports;
public DatabaseSink(Ib43Binding binding)
{
_db = new(new ConnectionConfig { ConnectionString = "DataSource=:memory:", DbType = DbType.Sqlite,
IsAutoCloseConnection = false, InitKeyType = InitKeyType.Attribute,
ConfigureExternalServices = new ConfigureExternalServices { EntityService = (p, c) =>
{
if (p.DeclaringType == typeof(HealthIb43Observation) && p.Name == "Id") c.DataType = "INTEGER";
} } });
_db.CodeFirst.InitTables<HealthIb43Binding, HealthIb43Observation>();
Repository = new(_db);
var key = Ib43DataRepository.DeviceKey(binding.ProductKey, binding.SerialNumber);
foreach (var (side, user) in new[] { ("left", 1L), ("right", 2L) })
_db.Insertable(new HealthIb43Binding { Id = side == "left" ? LeftBinding : "right-test", DeviceKey = key,
ProductKey = binding.ProductKey, SerialNumber = binding.SerialNumber, Side = side,
ActiveSlot = key + "." + side, UserId = user, Simulation = true }).ExecuteCommand();
}
public async Task<Ib43SavedReport> SaveAsync(string reportId, Ib43Binding binding, Ib43Report report, CancellationToken token)
{
var count = await Repository.Save(reportId, binding, report);
Interlocked.Increment(ref SavedReports);
return count;
}
public void Dispose() => _db.Dispose();
}
private static Task<MqttClientPublishResult> PublishPresence(IMqttClient publisher, Ib43Binding binding, int left, int right)
{
var payload = JsonSerializer.Serialize(new
{
method = "thing.property.post",
@params = new { deviceName = binding.SerialNumber, SleepReportNew = new { ibNew = new[] { left, 0, 0, 16, 74, 0, right, 0, 0, 17, 80, 0 } } }
});
return publisher.PublishAsync(new MqttApplicationMessageBuilder().WithTopic(binding.PostTopic).WithPayload(payload)
.WithQualityOfServiceLevel(MqttQualityOfServiceLevel.AtLeastOnce).Build());
}
private static async Task WaitFor(Func<bool> predicate)
{
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(12));
while (!predicate()) await Task.Delay(20, timeout.Token);
}
}
@@ -0,0 +1,345 @@
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;
using Xunit;
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;
private readonly Clock _clock = new();
private readonly Ib43Binding _device = new() { ProductKey = "test", SerialNumber = "sim", Simulation = true };
private readonly HttpContextAccessor _context = new() { HttpContext = new DefaultHttpContext() };
private readonly HealthIb43Service _service;
private readonly BedPresenceAutomation _automation;
private sealed class Clock : TimeProvider
{
public DateTimeOffset Now = DateTimeOffset.FromUnixTimeMilliseconds(1000000);
public override DateTimeOffset GetUtcNow() => Now;
}
public Ib43PersistenceTests()
{
_db = new SqlSugarClient(new ConnectionConfig { ConnectionString = "DataSource=" + _databasePath,
DbType = DbType.Sqlite, IsAutoCloseConnection = false, InitKeyType = InitKeyType.Attribute,
ConfigureExternalServices = new ConfigureExternalServices { EntityService = (property, column) =>
{
// SQLite 的 rowid 必须声明 INTEGER;MySQL 生产表保持 bigint identity。
if (property.DeclaringType == typeof(HealthIb43Observation) && property.Name == "Id") column.DataType = "INTEGER";
if (Nullable.GetUnderlyingType(property.PropertyType) != null) column.IsNullable = true;
} } });
_db.CodeFirst.InitTables<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);
_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(
[new Claim("health_user_id", id.ToString())], "test"));
private void Admin() => _context.HttpContext.User = new ClaimsPrincipal(new ClaimsIdentity(
[new Claim(ClaimConst.UserId, "99"), new Claim(ClaimConst.AccountType, "999")], "test"));
private async Task<Ib43BedOutput> Assign(string side, long user)
{
Admin();
return await _service.Assign(new() { ProductKey = "test", SerialNumber = "sim", Side = side, UserId = user, BedName = "测试床" });
}
private Ib43Report Report(string fields) => new Ib43Parser().Parse(_device.PostTopic,
"{\"method\":\"thing.property.post\",\"params\":{\"deviceName\":\"sim\"," + fields + "}}", _device, _clock.Now);
[Fact]
public async Task Stored_sides_are_isolated_and_survive_new_store_instance()
{
var left = await Assign("left", 1); var right = await Assign("right", 2);
await _repository.Save("a", _device, Report("\"HR\":[72,16,0,80,18,1]"));
User(1);
var current = await _service.Current(left.Id);
Assert.Equal(72d, current.Readings["heartRate"].Value);
Assert.Equal("unknown", current.Readings["presence"].Freshness);
Assert.Single(await _service.Bindings());
await ExpectRejection(() => _service.Current(right.Id));
await ExpectRejection(() => _service.History(right.Id, 0, 2000000));
_db.Ado.Close();
using var reopened = new SqlSugarClient(new ConnectionConfig { ConnectionString = "DataSource=" + _databasePath,
DbType = DbType.Sqlite, IsAutoCloseConnection = true, InitKeyType = InitKeyType.Attribute });
var freshRepository = new Ib43DataRepository(reopened);
Assert.Equal(80d, (await freshRepository.Latest(2, right.Id))["heartRate"].Value);
var rows = await _db.Queryable<HealthIb43Observation>().Where(o => o.UserId == 1).ToListAsync();
Assert.DoesNotContain("right", rows[0].ReadingsJson);
Assert.DoesNotContain("80", rows[0].ReadingsJson);
}
[Fact]
public async Task Partial_updates_and_zero_are_distinct_and_each_field_has_own_age()
{
var bed = await Assign("left", 1);
await _repository.Save("a", _device, Report("\"SleepReportNew\":{\"ibNew\":[1,0,0,16,74,0,0,0,0,0,0,0]}"));
_clock.Now += TimeSpan.FromSeconds(151);
await _repository.Save("b", _device, Report("\"HR\":[0,0,0,80,18,0]"));
User(1);
var current = await _service.Current(bed.Id);
Assert.Null(current.Readings["heartRate"].Value);
Assert.Equal("recent", current.Readings["heartRate"].Freshness);
Assert.Equal(1d, current.Readings["presence"].Value);
Assert.Equal("stale", current.Readings["presence"].Freshness);
Assert.Equal(2, (await _service.History(bed.Id, 0, 2000000)).Items.Count);
}
[Fact]
public async Task Reassignment_does_not_transfer_previous_users_data_or_delayed_reports()
{
var oldBed = await Assign("left", 1);
var pending = Report("\"HR\":[72,16,0,80,18,0]");
_clock.Now += TimeSpan.FromSeconds(1);
Admin(); await _service.Release(oldBed.Id);
var newBed = await Assign("left", 3);
await _repository.Save("delayed", _device, pending);
User(3);
Assert.Equal("unknown", (await _service.Current(newBed.Id)).Readings["heartRate"].Freshness);
Assert.Empty((await _service.History(newBed.Id, 0, 2000000)).Items);
await ExpectRejection(() => _service.Current(oldBed.Id));
await _repository.Save("new", _device, Report("\"HR\":[90,20,0,0,0,0]"));
Assert.Equal(90d, (await _service.Current(newBed.Id)).Readings["heartRate"].Value);
User(1);
Assert.Equal(72d, (await _service.Current(oldBed.Id)).Readings["heartRate"].Value);
Assert.False((await _service.Current(oldBed.Id)).Active);
}
[Fact]
public async Task Retries_are_idempotent_and_history_cursor_does_not_skip_same_timestamp()
{
var bed = await Assign("left", 1);
var report = Report("\"HR\":[72,16,0,80,18,0]");
await _repository.Save("a", _device, report);
await _repository.Save("a", _device, report);
await _repository.Save("b", _device, report);
User(1);
var page = await _service.History(bed.Id, 0, 2000000, take: 1);
Assert.NotNull(page.NextBeforeId);
var next = await _service.History(bed.Id, 0, 2000000, page.NextBeforeId, 1);
Assert.Single(next.Items); Assert.Null(next.NextBeforeId);
Assert.NotEqual(page.Items[0].Id, next.Items[0].Id);
await ExpectRejection(() => _service.History(bed.Id, 0, 8L * 86400000));
await ExpectRejection(() => _service.History(bed.Id, 0, 2000000, take: 201));
}
[Fact]
public async Task Anonymous_disabled_and_health_users_cannot_provision_or_read_others()
{
await ExpectRejection(() => _service.Bindings());
var bed = await Assign("left", 1);
User(1);
await ExpectRejection(() => _service.Assign(new() { ProductKey = "test", SerialNumber = "sim", Side = "right", UserId = 1, BedName = "test" }));
await ExpectRejection(() => _service.Release(bed.Id));
await _db.Updateable<HealthUser>().SetColumns(u => u.Status == HealthUserStatus.Disabled).Where(u => u.Id == 1).ExecuteCommandAsync();
await ExpectRejection(() => _service.Bindings());
await ExpectRejection(() => _service.Current(bed.Id));
}
[Fact]
public async Task Duplicate_active_slot_is_rejected_and_unbound_invalid_reports_are_not_saved()
{
await _repository.Save("unbound", _device, Report("\"HR\":[72,16,0,80,18,0]"));
Assert.Equal(0, await _db.Queryable<HealthIb43Observation>().CountAsync());
await Assign("left", 1);
await ExpectRejection(() => Assign("left", 2));
await _repository.Save("bad", _device, Report("\"HR\":[72]"));
Assert.Equal(0, await _db.Queryable<HealthIb43Observation>().CountAsync());
await Assign("right", 2);
}
private static async Task ExpectRejection(Func<Task> action)
{
var error = await Assert.ThrowsAnyAsync<Exception>(action);
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()
{
_db.CodeFirst.InitTables<HealthIb43MaintenanceAudit>();
var options = new Ib43Options { Devices = [new() { ProductKey = "evergreen-test", SerialNumber = "IB43SIM001", Simulation = true }] };
var tool = new Ib43LocalBinding(_db, _clock);
await tool.Run("1", options, "test-machine/test-operator", false);
Assert.Equal(0, await _db.Queryable<HealthIb43Binding>().CountAsync());
var first = await tool.Run("1", options, "test-machine/test-operator", true);
var repeat = await tool.Run("1", options, "test-machine/test-operator", true);
Assert.Equal(first.Id, repeat.Id);
Assert.Equal(0, first.AssignedBy);
Assert.Equal("left", first.Side);
var audit = await _db.Queryable<HealthIb43MaintenanceAudit>().SingleAsync();
Assert.Equal(first.Id, audit.BindingId);
Assert.Equal("test-machine/test-operator", audit.Actor);
await Assert.ThrowsAsync<InvalidOperationException>(() => tool.Run("2", options, "test", true));
Assert.Single(await _db.Queryable<HealthIb43Binding>().ToListAsync());
options.Devices[0].Simulation = false;
await Assert.ThrowsAsync<InvalidOperationException>(() => tool.Run("1", options, "test", true));
}
[Fact]
public async Task Local_maintenance_refuses_unknown_user_and_rolls_back_when_audit_fails()
{
var options = new Ib43Options { Devices = [new() { ProductKey = "evergreen-test", SerialNumber = "IB43SIM001", Simulation = true }] };
var tool = new Ib43LocalBinding(_db, _clock);
await Assert.ThrowsAsync<InvalidOperationException>(() => tool.Run("missing", options, "test", true));
// 故意不建审计表,验证绑定不会单独提交。
await Assert.ThrowsAsync<InvalidOperationException>(() => tool.Run("1", options, "test", true));
Assert.Equal(0, await _db.Queryable<HealthIb43Binding>().CountAsync());
}
public void Dispose()
{
_automation.Dispose();
_db.Dispose();
Microsoft.Data.Sqlite.SqliteConnection.ClearAllPools();
File.Delete(_databasePath);
}
}
+205
View File
@@ -0,0 +1,205 @@
using Evergreen.Health.Application.Health.Integrations.Ib43;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using System.Text.Json;
using Xunit;
namespace Admin.NET.Test.Health;
public sealed class Ib43Tests
{
private static Ib43Binding Binding() => new() { ProductKey = "evergreen-test", SerialNumber = "IB43SIM001" };
private static string Report(string fields) => "{\"method\":\"thing.property.post\",\"params\":{\"deviceName\":\"IB43SIM001\"," + fields + "}}";
private static Ib43Report Parse(string fields) => new Ib43Parser().Parse(Binding().PostTopic, Report(fields), Binding(), DateTimeOffset.UtcNow);
private sealed class Clock : TimeProvider
{
public DateTimeOffset Now = DateTimeOffset.UtcNow;
public override DateTimeOffset GetUtcNow() => Now;
}
private static Ib43StateStore Store(Clock clock) => new(Options.Create(new Ib43Options { Devices = [Binding()] }), new(), clock);
[Theory]
[InlineData("startup.pdf-example.json", "startup")]
[InlineData("realtime.pdf-example.json", "periodic")]
[InlineData("sleep-event.pdf-keys.json", "event")]
public void Archived_supplier_examples_parse_without_issues(string file, string kind)
{
var json = File.ReadAllText(Path.Combine(AppContext.BaseDirectory, "Ib43Examples", file));
using var doc = JsonDocument.Parse(json);
var p = doc.RootElement.GetProperty("params");
var binding = new Ib43Binding { SerialNumber = p.GetProperty("deviceName").GetString(),
ProductKey = p.TryGetProperty("productKey", out var pk) ? pk.GetString() : "example" };
var report = new Ib43Parser().Parse(binding.PostTopic, json, binding, DateTimeOffset.UtcNow);
Assert.True(report.Accepted);
Assert.Empty(report.Issues);
Assert.Contains(kind, report.Kinds);
if (kind == "startup") Assert.Equal(60, report.Fields["characteristic"].GetProperty("reportIerVal").GetInt32());
if (kind == "periodic") Assert.Equal(-52, report.Fields["airbagsPressure"][3].GetInt32());
if (kind == "event") Assert.Equal(80d, report.Readings["right.heartRate"].Value);
}
[Fact]
public void Periodic_HR_uses_confirmed_order_and_does_not_infer_presence()
{
var r = Parse("\"HR\":[72,16,1,80,18,0],\"snoreStatus\":[3,9],\"airbagsPerson\":[1,0]");
Assert.Equal(72d, r.Readings["left.heartRate"].Value);
Assert.Equal(18d, r.Readings["right.respiration"].Value);
Assert.Equal(1d, r.Readings["left.snoring"].Value);
Assert.Equal(0d, r.Readings["right.snoring"].Value);
Assert.DoesNotContain("left.presence", r.Readings.Keys);
}
[Theory]
[InlineData("SleepReportNew", "ibNew")]
[InlineData(" SleepReportNew", " ibNew")]
[InlineData(" SleepReportNew", "ibNew")]
public void Event_has_its_own_order_and_preserves_sides(string outer, string inner)
{
var r = Parse($"\"{outer}\":{{\"{inner}\":[1,0,0,16,74,0,0,1,0,17,80,0]}}");
Assert.Equal(74d, r.Readings["left.heartRate"].Value);
Assert.Equal(17d, r.Readings["right.respiration"].Value);
Assert.Equal(1d, r.Readings["left.presence"].Value);
Assert.Equal(0d, r.Readings["right.presence"].Value);
}
[Fact]
public void Partial_updates_preserve_presence_but_zero_clears_previous_vitals()
{
var clock = new Clock(); var store = Store(clock);
store.Receive(Binding().PostTopic, Report("\"SleepReportNew\":{\"ibNew\":[1,0,0,16,74,0,0,0,0,0,0,0]}"));
var first = store.Snapshot()[0].Readings["left.presence"].ReceivedAt;
clock.Now += TimeSpan.FromSeconds(5);
store.Receive(Binding().PostTopic, Report("\"HR\":[0,0,0,80,18,1]"));
var state = store.Snapshot()[0];
Assert.Equal(1d, state.Readings["left.presence"].Value);
Assert.Equal(first, state.Readings["left.presence"].ReceivedAt);
Assert.Null(state.Readings["left.heartRate"].Value);
Assert.Equal(80d, state.Readings["right.heartRate"].Value);
}
[Theory]
[InlineData("\"HR\":[1,2]", "HR:invalid-length")]
[InlineData("\"HR\":[72,16,0,80,\"18\",0]", "HR:invalid-number")]
[InlineData("\"HR\":[72,16,4,80,18,0]", "left.snoring:invalid-enum")]
[InlineData("\"motorStatus\":[0,0,0,9]", "motorStatus:invalid-enum")]
[InlineData("\"SleepReportNew\":{},\" SleepReportNew\":{}", "SleepReportNew:ambiguous-alias")]
[InlineData("\"SleepReportNew\":{\"ibNew\":[],\" ibNew\":[]}", "ibNew:ambiguous-alias")]
public void Malformed_fields_record_issues(string fields, string issue)
=> Assert.Contains(issue, Parse(fields).Issues);
[Theory]
[InlineData("{", "invalid-json")]
[InlineData("[]", "invalid-envelope")]
[InlineData("{\"method\":\"x\",\"params\":{}}", "invalid-envelope")]
[InlineData("{\"method\":\"thing.property.post\",\"params\":{\"deviceName\":\"other\"}}", "identity-mismatch")]
[InlineData("{\"method\":\"thing.property.post\",\"params\":{\"deviceName\":\"IB43SIM001\",\"productKey\":\"other\"}}", "identity-mismatch")]
[InlineData("{\"method\":\"thing.property.post\",\"params\":{\"deviceName\":\"IB43SIM001\",\"HR\":[],\"HR\":[]}}", "duplicate-json-key")]
public void Rejects_invalid_envelopes_and_identity(string raw, string issue)
{
var r = new Ib43Parser().Parse(Binding().PostTopic, raw, Binding(), DateTimeOffset.UtcNow);
Assert.False(r.Accepted); Assert.Contains(issue, r.Issues); Assert.Empty(r.Readings);
}
[Fact]
public void Old_events_are_not_erased_by_timeout_and_transport_is_independent()
{
var clock = new Clock(); var store = Store(clock);
Assert.Equal("unknown", store.Snapshot()[0].Freshness);
store.SetTransport("connected");
store.Receive(Binding().PostTopic, Report("\"SleepReportNew\":{\"ibNew\":[1,0,0,16,74,0,0,0,0,0,0,0]}"));
clock.Now += TimeSpan.FromSeconds(151);
store.Receive(Binding().PostTopic, Report("\"HR\":[0]"));
store.Receive(Binding().PostTopic, Report("\"firmwareVer\":\"new\""));
var snapshot = store.Snapshot()[0];
Assert.Equal("stale", snapshot.Freshness);
Assert.Equal("connected", snapshot.Transport);
Assert.Equal(1d, snapshot.Readings["left.presence"].Value);
store.SetTransport("disconnected");
Assert.Equal("disconnected", store.Snapshot()[0].Transport);
}
[Fact]
public void Unbound_retained_and_oversized_reports_never_update_live_state_and_history_is_bounded()
{
var store = Store(new()); var payload = Report("\"HR\":[72,16,0,80,18,0]");
Assert.False(store.Receive("/sys/other/IB43SIM001/thing/property/post", payload).Accepted);
Assert.False(store.Receive(Binding().PostTopic, payload, true).Accepted);
Assert.False(store.Receive(Binding().PostTopic, new string('x', 65537)).Accepted);
Assert.Equal("unknown", store.Snapshot()[0].Freshness);
for (var i = 0; i < 100; i++) store.Receive(Binding().PostTopic, "bad-json");
Assert.Equal(32, store.Diagnostics().Length);
Assert.Equal("bad-json", store.Diagnostics()[0].Raw);
}
[Theory]
[InlineData(0, 1, 0)]
[InlineData(1, 0, 1)]
public void Presence_mapping_requires_explicit_configuration(int index, int left, int right)
{
var binding = Binding(); binding.PersonLeftIndex = index;
var r = new Ib43Parser().Parse(binding.PostTopic, Report("\"airbagsPerson\":[1,0]"), binding, DateTimeOffset.UtcNow);
Assert.Equal((double)left, r.Readings["left.presence"].Value);
Assert.Equal((double)right, r.Readings["right.presence"].Value);
}
[Fact]
public void Enabled_configuration_requires_credentials_and_exact_unique_topics()
{
var options = new Ib43Options(); Assert.True(options.IsValid());
options.Enabled = true; Assert.False(options.IsValid());
options.Password = "test-only"; options.Devices = [Binding()]; Assert.True(options.IsValid());
options.Devices.Add(Binding()); Assert.False(options.IsValid());
options.Devices = [new() { ProductKey = "+", SerialNumber = "#" }]; Assert.False(options.IsValid());
}
[Fact]
public async Task Disabled_service_never_creates_connection()
{
var factory = new FakeFactory();
using var service = new Ib43MqttService(Options.Create(new Ib43Options()), factory, Store(new()),
TimeProvider.System, NullLogger<Ib43MqttService>.Instance);
await service.StartAsync(default); await service.StopAsync(default);
Assert.Equal(0, factory.Count);
}
[Fact]
public async Task Service_retries_failed_subscription_then_receives_and_stops()
{
var factory = new FakeFactory(); var store = Store(new());
using var service = new Ib43MqttService(Options.Create(new Ib43Options { Enabled = true,
Password = "test-only", Devices = [Binding()] }), factory, store,
TimeProvider.System, NullLogger<Ib43MqttService>.Instance);
await service.StartAsync(default);
try
{
await factory.Connected.Task.WaitAsync(TimeSpan.FromSeconds(5));
Assert.Equal(2, factory.Count);
Assert.Equal(72d, store.Snapshot()[0].Readings["left.heartRate"].Value);
}
finally { await service.StopAsync(default); }
Assert.Equal("disconnected", store.Snapshot()[0].Transport);
Assert.True(factory.Last.Disposed);
}
private sealed class FakeFactory : IIb43MqttTransportFactory
{
public int Count;
public FakeTransport Last;
public TaskCompletionSource Connected = new(TaskCreationOptions.RunContinuationsAsynchronously);
public IIb43MqttTransport Create() => Last = new FakeTransport(++Count == 1, Connected);
}
private sealed class FakeTransport(bool fail, TaskCompletionSource connected) : IIb43MqttTransport
{
public bool Disposed;
public bool IsConnected => !Disposed;
public event Func<string, string, bool, Task> Message;
public event Action<string> OversizedMessage { add { } remove { } }
public async Task ConnectAndSubscribeAsync(Ib43Options options, CancellationToken cancellationToken)
{
if (fail) throw new InvalidOperationException("subscription denied");
if (Message != null) await Message.Invoke(options.Devices[0].PostTopic, Report("\"HR\":[72,16,0,80,18,0]"), false);
connected.TrySetResult();
}
public void Dispose() => Disposed = true;
}
}
@@ -0,0 +1,188 @@
using Evergreen.Health.Application.Health.Devices;
using Evergreen.Health.Application.Health.Devices.Contracts;
using Evergreen.Health.Application.Health.Devices.Discovery;
using Evergreen.Health.Application.Health.Devices.Outlet;
using Evergreen.Health.Application.Health.Integrations.HomeAssistant;
using Evergreen.Health.Application.Health.Models;
using Evergreen.Health.Application.Health.Scenes;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
using Xunit;
namespace Admin.NET.Test.Health;
/// <summary>仅内存假网关,不连接 HA、不使实际插座通电。</summary>
public sealed class OutletIntegrationTests
{
private const string MainId = "switch.test_outlet_power", ServiceId = "switch.test_outlet_service";
private static HomeAssistantState State(string id, string value) => new() { EntityId = id, State = value };
private static DeviceActionRunner Runner(IHomeAssistantGateway gateway) => new(gateway, TimeProvider.System,
new() { VerificationDelay = TimeSpan.Zero, VerificationAttempts = 3 });
private static IOptions<HomeAssistantOptions> Settings(string id = MainId) => Options.Create(new HomeAssistantOptions
{ Entities = new() { Outlet = id } });
private static HomeAssistantRegistries Registry(params string[] entityIds) => new()
{
Devices = [new() { Id = "outlet-device", Name = "红灯插座", Model = "ym001.plug.ymcz" }],
Entities = entityIds.Select(id => new HomeAssistantEntityRegistryItem
{ DeviceId = "outlet-device", EntityId = id }).ToList()
};
[Theory]
[InlineData(true, "off", "turn_on", "on")]
[InlineData(false, "on", "turn_off", "off")]
public async Task ManualPowerUsesOnlyTrustedPrimaryAndConfirmsReadback(bool turnOn, string current, string service, string expected)
{
var gateway = new FakeHaGateway();
gateway.States[MainId] = State(MainId, current);
gateway.States[ServiceId] = State(ServiceId, "off");
var module = new OutletModule(Runner(gateway), Settings());
await module.SetPowerAsync(turnOn, default);
var call = Assert.Single(gateway.Calls);
Assert.Equal("switch", call.Domain);
Assert.Equal(service, call.Service);
Assert.Equal(MainId, call.EntityId);
Assert.Equal(MainId, call.Data!["entity_id"]);
Assert.Single(call.Data);
Assert.Equal(expected, gateway.States[MainId].State);
Assert.Equal("off", gateway.States[ServiceId].State);
}
[Theory]
[InlineData("unavailable")]
[InlineData("unknown")]
[InlineData("")]
[InlineData("idle")]
public async Task UnconfirmedPrimaryNeverBorrowsServiceSwitchAvailability(string state)
{
var gateway = new FakeHaGateway();
gateway.States[MainId] = State(MainId, state);
gateway.States[ServiceId] = State(ServiceId, "on");
var module = new OutletModule(Runner(gateway), Settings());
await Assert.ThrowsAsync<InvalidOperationException>(() => module.SetPowerAsync(true, default));
await Assert.ThrowsAsync<InvalidOperationException>(() => module.SetPowerAsync(false, default));
Assert.Empty(gateway.Calls);
}
[Fact]
public async Task MissingPrimaryAndReadFailuresNeverSendPowerCommands()
{
var gateway = new FakeHaGateway();
gateway.States[ServiceId] = State(ServiceId, "on");
var module = new OutletModule(Runner(gateway), Settings());
await Assert.ThrowsAsync<InvalidOperationException>(() => module.SetPowerAsync(true, default));
gateway.States[MainId] = State(MainId, "off");
gateway.ReadsToFail = 1;
await Assert.ThrowsAsync<IOException>(() => module.SetPowerAsync(true, default));
Assert.Empty(gateway.Calls);
}
[Theory]
[InlineData("")]
[InlineData("button.toggle")]
[InlineData("number.protection")]
public async Task InvalidBindingCannotFallBackToToggleOrEngineeringParameters(string id)
{
var gateway = new FakeHaGateway();
var module = new OutletModule(Runner(gateway), Settings(id));
await Assert.ThrowsAsync<InvalidOperationException>(() => module.SetPowerAsync(true, default));
Assert.Throws<InvalidOperationException>(() => module.BuildParameterCommand("outlet_service", true));
Assert.Empty(gateway.Calls);
}
[Fact]
public async Task CancellationPreventsManualPower()
{
var gateway = new FakeHaGateway(); gateway.States[MainId] = State(MainId, "off");
using var cancellation = new CancellationTokenSource(); cancellation.Cancel();
var module = new OutletModule(Runner(gateway), Settings());
await Assert.ThrowsAnyAsync<OperationCanceledException>(() => module.SetPowerAsync(true, cancellation.Token));
Assert.Empty(gateway.Calls);
}
[Fact]
public async Task AcceptedButUnconfirmedCommandIsNotSuccessAndDoesNotRepeatEnergizing()
{
var gateway = new NonApplyingGateway();
var module = new OutletModule(Runner(gateway), Settings());
var error = await Assert.ThrowsAsync<InvalidOperationException>(() => module.SetPowerAsync(true, default));
Assert.Contains("未确认目标状态", error.Message);
Assert.Equal(1, gateway.Writes);
Assert.Equal(4, gateway.Reads); // 发送前校验 + 三轮回读,不再次发送。
}
[Fact]
public void DiscoverySelectsExplicitPowerEvenWhenServiceSwitchComesFirst()
{
var gateway = new FakeHaGateway();
gateway.States[MainId] = State(MainId, "off"); gateway.States[ServiceId] = State(ServiceId, "on");
var options = Settings();
var module = new OutletModule(Runner(gateway), options);
var modules = new DeviceModuleRegistry([module]);
var catalog = new HomeAssistantDeviceCatalog(options, modules);
var item = Assert.Single(catalog.BuildDiscoveredDevices(Registry(ServiceId, "button.toggle", MainId), gateway.States));
Assert.Equal("outlet", item.ModuleKey);
Assert.Equal("Outlet", item.Category);
Assert.Equal(MainId, item.PrimaryControlEntityId);
Assert.True(item.Online);
Assert.Equal("off", item.Entities.Single(entity => entity.EntityId == item.PrimaryControlEntityId).State);
Assert.Null(modules.ForEntities([ServiceId, "button.toggle"]));
Assert.Empty(gateway.Calls);
}
[Fact]
public void UnconfiguredOutletHasNoGuessedPrimaryOrFallbackDevice()
{
var gateway = new FakeHaGateway(); gateway.States[ServiceId] = State(ServiceId, "on");
var options = Settings("");
var catalog = new HomeAssistantDeviceCatalog(options,
new DeviceModuleRegistry([new OutletModule(Runner(gateway), options)]));
var item = Assert.Single(catalog.BuildDiscoveredDevices(Registry(ServiceId), gateway.States));
Assert.Equal("", item.ModuleKey);
Assert.Null(item.PrimaryControlEntityId);
Assert.Empty(catalog.BuildDiscoveredDevices(null, gateway.States));
}
[Fact]
public async Task SleepAndAllOffDoNotChangeOutletPowerOrExposeScenePreset()
{
var gateway = new FakeHaGateway(); gateway.States[MainId] = State(MainId, "on");
var runner = Runner(gateway);
var module = new OutletModule(runner, Settings());
var coordinator = new HealthSceneCoordinator(new DeviceModuleRegistry([module]), runner, gateway, new FakeLight(), new FakeMusic());
await coordinator.ExecuteAsync("sleep", new PersonalizedSceneConfig(), "balanced", default);
await coordinator.ExecuteAsync("all-off", null, "", default);
Assert.Empty(module.GetShutdownCommands());
Assert.Null(module.BuildPreview(new(), "balanced"));
Assert.Empty(gateway.Calls);
Assert.Equal("on", gateway.States[MainId].State);
}
[Fact]
public void OutletIsRegisteredAsItsOwnModuleWithoutHostedAutomation()
{
var services = new ServiceCollection();
services.AddLogging(); services.AddOptions(); services.AddHttpClient(); services.AddHealthDevices();
services.AddSingleton(Settings());
using var provider = services.BuildServiceProvider(new ServiceProviderOptions { ValidateOnBuild = true, ValidateScopes = true });
var module = provider.GetRequiredService<OutletModule>();
Assert.Same(module, provider.GetRequiredService<DeviceModuleRegistry>().Modules.Single(item => item.Key == "outlet"));
Assert.Empty(module.Parameters);
}
private sealed class NonApplyingGateway : IHomeAssistantGateway
{
public int Writes { get; private set; }
public int Reads { get; private set; }
public Task<IReadOnlyList<HomeAssistantState>> GetStatesAsync(CancellationToken cancellationToken = default)
{
cancellationToken.ThrowIfCancellationRequested(); Reads++;
return Task.FromResult<IReadOnlyList<HomeAssistantState>>([State(MainId, "off")]);
}
public Task CallServiceAsync(string domain, string service, IReadOnlyDictionary<string, object?> data,
CancellationToken cancellationToken = default)
{
cancellationToken.ThrowIfCancellationRequested(); Writes++;
return Task.CompletedTask;
}
}
}
@@ -0,0 +1,56 @@
using Evergreen.Health.Application.Health.Devices;
using Evergreen.Health.Application.Health.Devices.Discovery;
using Evergreen.Health.Application.Health.Integrations.HomeAssistant;
using Evergreen.Health.Application.Health.Scenes;
using Evergreen.Health.Application.Health.Devices.Contracts;
using Microsoft.Extensions.Options;
using Xunit;
namespace Admin.NET.Test.Health;
public sealed class RetiredDeviceTests
{
[Theory]
[InlineData(null)]
[InlineData("user")]
public void ExplicitRetiredBindingExcludesEntireDeviceEvenWhenBindingIsDisabled(string? disabledBy)
{
var catalog = new HomeAssistantDeviceCatalog(Options.Create(new HomeAssistantOptions
{
ExcludedDeviceEntityIds = ["number.old_heat"]
}), new DeviceModuleRegistry([]));
var registry = new HomeAssistantRegistries
{
Devices = [new() { Id = "retired", Name = "智能床垫" }, new() { Id = "new", Name = "智能床垫" }],
Entities = [new() { DeviceId = "retired", EntityId = "NUMBER.OLD_HEAT", DisabledBy = disabledBy },
new() { DeviceId = "retired", EntityId = "switch.old" },
new() { DeviceId = "new", EntityId = "sensor.presence" }]
};
var states = new Dictionary<string, HomeAssistantState>
{
["switch.old"] = new() { EntityId = "switch.old", State = "on" },
["sensor.presence"] = new() { EntityId = "sensor.presence", State = "on" }
};
var remaining = Assert.Single(catalog.BuildDiscoveredDevices(registry, states));
Assert.Equal("new", remaining.DeviceId);
Assert.Equal("", remaining.ModuleKey);
registry.Devices.RemoveAt(1);
Assert.Empty(catalog.BuildDiscoveredDevices(registry, states));
Assert.Empty(catalog.BuildDiscoveredDevices(null, states));
}
[Fact]
public async Task PrimaryLightCommandsStillTrackKnownOffStateWithoutMattressRules()
{
var state = new SceneLightSafetyState();
var gateway = new FakeHaGateway();
var runner = new DeviceActionRunner(gateway, TimeProvider.System, new(), [new PrimaryLightStateRule(state)]);
await runner.SendAsync(new("关灯", "light.room", "light", "turn_off")
{ Intent = DeviceCommandIntent.TurnRelaxationLightOff }, default);
Assert.NotNull(state.OffAt);
await runner.SendAsync(new("开灯", "light.room", "light", "turn_on")
{ Intent = DeviceCommandIntent.TurnRelaxationLightOn }, default);
Assert.Null(state.OffAt);
Assert.All(gateway.Calls, call => Assert.Equal("light.room", call.EntityId));
}
}
@@ -0,0 +1,238 @@
using Evergreen.Health.Application.Health.Devices;
using Evergreen.Health.Application.Health.Devices.Contracts;
using Evergreen.Health.Application.Health.Integrations.HomeAssistant;
using Evergreen.Health.Application.Health.Integrations.MiAirX;
using Evergreen.Health.Application.Health.Integrations.Tuya;
using Evergreen.Health.Application.Health.Policies;
using Evergreen.Health.Application.Health.Scenes;
using Evergreen.Health.Application.Health.Sleep;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using System.Text.Json;
using Xunit;
namespace Admin.NET.Test.Health;
public sealed class SafetyAndReservedWorkflowTests
{
private static readonly DateTimeOffset Now = new(2026, 10, 8, 0, 0, 0, TimeSpan.Zero);
private static SleepObservation Reading(DateTimeOffset at, BedPresence presence = BedPresence.Occupied,
SleepDataAvailability availability = SleepDataAvailability.Available) => new("fake", at, availability, presence);
private static HomeAssistantOptions Configuration() => new()
{
Entities = new() { AmbientLight = "light.bedroom" }
};
private static HomeAssistantState Light(string state, double brightness, DateTimeOffset updated) => new()
{
EntityId = "light.bedroom", State = state, LastUpdated = updated,
Attributes = JsonSerializer.SerializeToElement(new { brightness })
};
[Fact]
public async Task UnconfiguredInputsAndPlaceholderAudioCannotPretendToWork()
{
var observation = await new ReservedJe01Source().ReadAsync(default);
Assert.Equal(BedPresence.Unknown, observation.Presence);
Assert.Equal(SleepDataAvailability.Unconfigured, observation.Availability);
var clip = new GuidanceCatalog().InitialRelaxation;
Assert.False(clip.Playable);
Assert.False((await new ReservedGuidancePlayer().PlayAsync(clip, 50, default)).Completed);
var tuya = new ReservedTuyaGateway();
Assert.False(tuya.IsConfigured);
await Assert.ThrowsAsync<NotSupportedException>(() => tuya.WriteAsync(new("fake", "switch", true), default));
}
[Fact]
public void DefaultWorkflowNeverPlansBedOrDeviceActions()
{
var workflow = new ReservedSleepWorkflow(new(), new());
Assert.Empty(workflow.Start());
Assert.Empty(workflow.Observe(Reading(Now), Now.AddMinutes(1)));
Assert.Empty(workflow.Tick(Now.AddMinutes(10)));
Assert.Equal(ReservedSleepPhase.Disabled, workflow.Phase);
}
[Theory]
[InlineData(BedPresence.Unknown, SleepDataAvailability.Available, 0)]
[InlineData(BedPresence.Empty, SleepDataAvailability.Available, 0)]
[InlineData(BedPresence.Occupied, SleepDataAvailability.Offline, 0)]
[InlineData(BedPresence.Occupied, SleepDataAvailability.Stale, 0)]
[InlineData(BedPresence.Occupied, SleepDataAvailability.Available, 121)]
public void UnknownOfflineOrStaleInputResetsContinuousBedWait(BedPresence presence, SleepDataAvailability availability, int age)
{
var workflow = new ReservedSleepWorkflow(new() { Enabled = true }, new());
Assert.Equal(SleepWorkflowActionKind.PrepareEnvironment, Assert.Single(workflow.Start()).Kind);
Assert.Empty(workflow.Observe(Reading(Now), Now));
Assert.Empty(workflow.Observe(Reading(Now.AddSeconds(29 - age), presence, availability), Now.AddSeconds(29)));
Assert.Equal(ReservedSleepPhase.WaitingForBed, workflow.Phase);
Assert.Empty(workflow.Observe(Reading(Now.AddSeconds(30)), Now.AddSeconds(30)));
Assert.Empty(workflow.Observe(Reading(Now.AddSeconds(59)), Now.AddSeconds(59)));
Assert.Equal(1, workflow.Observe(Reading(Now.AddSeconds(60)), Now.AddSeconds(60)).Count);
}
[Fact]
public void PlaceholderCompletionCannotStartMusicOrLightFade()
{
var workflow = new ReservedSleepWorkflow(new() { Enabled = true }, new());
workflow.Start(); workflow.Observe(Reading(Now), Now);
workflow.Observe(Reading(Now.AddSeconds(30)), Now.AddSeconds(30));
Assert.Empty(workflow.GuidanceCompleted("initial-relaxation", true, Now.AddSeconds(40)));
Assert.Empty(workflow.Tick(Now.AddMinutes(5)));
Assert.Equal(ReservedSleepPhase.Guiding, workflow.Phase);
}
[Fact]
public void RealFutureGuideRestartsAtFiftyAndReminderDoesNotRestartCompletedLampFade()
{
var clip = new GuidanceClip("guide", "测试", "https://example.invalid/guide.mp3", 20);
var workflow = new ReservedSleepWorkflow(new() { Enabled = true }, new(clip));
workflow.Start(); workflow.Observe(Reading(Now), Now);
Assert.Empty(workflow.Observe(Reading(Now.AddSeconds(29)), Now.AddSeconds(29)));
var actions = workflow.Observe(Reading(Now.AddSeconds(30)), Now.AddSeconds(30));
Assert.Single(actions);
Assert.Equal(50, actions.Single(action => action.Kind == SleepWorkflowActionKind.RequestGuidance).VolumePercent);
Assert.Empty(workflow.GuidanceCompleted("wrong", true, Now.AddSeconds(50)));
var music = Assert.Single(workflow.GuidanceCompleted("guide", true, Now.AddSeconds(50)));
Assert.Equal(SleepWorkflowActionKind.RestartMusicAtFifty, music.Kind);
Assert.Equal(50, music.VolumePercent);
Assert.Empty(workflow.Tick(Now.AddSeconds(79)));
Assert.Equal(SleepWorkflowActionKind.StartLightFade, Assert.Single(workflow.Tick(Now.AddSeconds(80))).Kind);
Assert.Empty(workflow.Tick(Now.AddSeconds(81)));
Assert.Single(workflow.RequestReminder(clip));
Assert.Single(workflow.GuidanceCompleted("guide", true, Now.AddMinutes(6)));
Assert.Empty(workflow.Tick(Now.AddMinutes(7)));
workflow.Stop();
Assert.Empty(workflow.Observe(Reading(Now.AddMinutes(8)), Now.AddMinutes(8)));
Assert.Empty(workflow.RequestReminder(clip));
}
[Fact]
public void SameAnomalyCooldownOnlyStartsAfterActualDelivery()
{
var gate = new AnomalyReminderGate(new());
Assert.True(gate.CanRemind("restless", Now));
Assert.True(gate.CanRemind("restless", Now.AddMinutes(1)));
gate.MarkDelivered("restless", Now);
Assert.False(gate.CanRemind("RESTLESS", Now.AddMinutes(29)));
Assert.True(gate.CanRemind("restless", Now.AddMinutes(30)));
Assert.True(gate.CanRemind("another", Now));
}
[Fact]
public async Task NaturalFadeToZeroOnlyControlsTheLight()
{
var time = new ManualTestTimeProvider();
var gateway = new FakeHaGateway { Time = time };
gateway.States["light.bedroom"] = Light("on", 100, time.GetUtcNow());
var runner = new DeviceActionRunner(gateway, time, new() { VerificationDelay = TimeSpan.Zero });
var safety = new SceneLightSafetyState();
var light = new HomeAssistantLightTransitionService(gateway, new([]), Options.Create(Configuration()),
NullLogger<HomeAssistantLightTransitionService>.Instance, safety, time);
await light.StartSleepAsync(2700, default);
for (var step = 1; step <= 50; step++)
{
time.Advance(TimeSpan.FromSeconds(5));
var expected = 50 - step;
await WaitUntilAsync(() => light.GetStatus().BrightnessPercent == expected && (step != 50 || !light.GetStatus().Running));
}
Assert.Equal("off", gateway.States["light.bedroom"].State);
Assert.NotNull(safety.OffAt);
Assert.All(gateway.Calls, command => Assert.Equal("light.bedroom", command.EntityId));
await light.StopAsync(default);
}
[Fact]
public async Task TaskCancellationAloneCannotCountAsDeviceSceneSuccess()
{
var gateway = new FakeHaGateway();
var runner = new DeviceActionRunner(gateway, TimeProvider.System, new());
var coordinator = new HealthSceneCoordinator(new([]), runner, gateway, new FakeLight(), new FakeMusic());
Assert.Equal("Failed", (await coordinator.ExecuteAsync("sleep", new(), "balanced", default)).Status);
}
[Fact]
public void SessionClimateTargetIsIdempotentAndPreviewIsNotRandomized()
{
var advice = RoomTemperaturePolicy.Evaluate(new(15, Now, true, ""), new(22, 25));
Assert.Null(advice.TargetTemperatureCelsius);
RoomTemperaturePolicy.PickTargetForSession(advice);
var initial = advice.TargetTemperatureCelsius;
for (var i = 0; i < 20; i++) RoomTemperaturePolicy.PickTargetForSession(advice);
Assert.Equal(initial, advice.TargetTemperatureCelsius);
}
[Fact]
public async Task GatewayTimeoutIsFailureNotUserCancellationSoOtherDevicesCanContinue()
{
var ha = new HomeAssistantClient(new HttpClient(new TimedOutHandler()), Options.Create(new HomeAssistantOptions
{ Enabled = true, BaseUrl = "https://example.invalid", AccessToken = "fake-test-token" }));
await Assert.ThrowsAsync<TimeoutException>(() => ha.GetStatesAsync());
var audio = new MiAirXClient(new HttpClient(new TimedOutHandler()), Options.Create(new MiAirXOptions
{ Enabled = true, BaseUrl = "https://example.invalid", DeviceId = "fake-test-device", RequireWebPassword = false }));
await Assert.ThrowsAsync<TimeoutException>(() => audio.StopAsync());
using var cancelled = new CancellationTokenSource(); cancelled.Cancel();
await Assert.ThrowsAnyAsync<OperationCanceledException>(() => ha.GetStatesAsync(cancelled.Token));
await Assert.ThrowsAnyAsync<OperationCanceledException>(() => audio.StopAsync(cancelled.Token));
}
[Fact]
public async Task UnintegratedCurtainNeverSendsOrReportsSuccessfulControl()
{
var gateway = new FakeHaGateway();
var runner = new DeviceActionRunner(gateway, TimeProvider.System, new());
var module = new Evergreen.Health.Application.Health.Devices.Curtain.CurtainModule(runner,
Options.Create(new HomeAssistantOptions()));
var context = new DeviceSceneContext(runner, new Dictionary<string, HomeAssistantState>(), []);
await module.ExecuteSleepAsync(context, new(), "balanced", default);
Assert.Equal("Skipped", Assert.Single(context.Results).Status);
Assert.Empty(gateway.Calls);
Assert.Empty(module.GetShutdownCommands());
}
[Fact]
public async Task AllOffAlsoCancelsOldPreparationBeforeAnyDeviceCanStart()
{
var gateway = new FakeHaGateway();
var runner = new DeviceActionRunner(gateway, TimeProvider.System, new() { VerificationDelay = TimeSpan.Zero });
var coordinator = new HealthSceneCoordinator(new([new SyntheticDeviceModule(runner)]), runner, gateway, new FakeLight(), new FakeMusic());
var preparing = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
var start = coordinator.ExecutePreparedSleepAsync(async token =>
{
preparing.SetResult();
await Task.Delay(Timeout.InfiniteTimeSpan, token);
return new(new(), "balanced");
}, default);
await preparing.Task;
var off = await coordinator.ExecuteAsync("all-off", null, "", default);
Assert.Equal("Cancelled", (await start).Status);
Assert.Equal("Succeeded", off.Status);
Assert.DoesNotContain(gateway.Calls, command => command.Service == "set_value");
}
[Fact]
public async Task MissingConstitutionInPreparationDoesNotExecuteOrCountCancellationAsSuccess()
{
var gateway = new FakeHaGateway();
var runner = new DeviceActionRunner(gateway, TimeProvider.System, new());
var music = new FakeMusic();
var coordinator = new HealthSceneCoordinator(new([]), runner, gateway, new FakeLight(), music);
var output = await coordinator.ExecutePreparedSleepAsync(_ => throw new ScenePreparationException("先完成四诊"), default);
Assert.False(output.Success);
Assert.Equal("AssessmentRequired", output.Status);
Assert.Equal("先完成四诊", output.Message);
Assert.Empty(gateway.Calls);
Assert.Equal(0, music.StopCount);
}
private sealed class TimedOutHandler : HttpMessageHandler
{
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
=> Task.FromException<HttpResponseMessage>(new TaskCanceledException("模拟网络超时"));
}
private static async Task WaitUntilAsync(Func<bool> predicate)
{
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(3));
while (!predicate()) await Task.Delay(5, timeout.Token);
}
}
@@ -0,0 +1,321 @@
using Evergreen.Health.Application.Health.Devices;
using Evergreen.Health.Application.Health.Devices.Climate;
using Evergreen.Health.Application.Health.Devices.Contracts;
using Evergreen.Health.Application.Health.Devices.Discovery;
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.Integrations.MiAirX;
using Evergreen.Health.Application.Health.Models;
using Evergreen.Health.Application.Health.Policies;
using Evergreen.Health.Application.Health.Scenes;
using Evergreen.Health.Application.Health.Services;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using System.Text.Json;
using Xunit;
namespace Admin.NET.Test.Health;
/// <summary>两盏灯和空调的真实模块组合,全部使用内存网关与可推进时钟,绝不控制实机。</summary>
public sealed class SceneLightingIntegrationTests
{
private const string Primary = "light.bedroom", Strip = "light.strip", Climate = "climate.room", Temperature = "sensor.indoor";
private static SceneLightOptions StripSettings() => new() { Key = "sleep-light-strip", Name = "测试助眠灯带", EntityId = Strip, SleepEffect = "助眠模式" };
private static HomeAssistantOptions Settings() => new()
{
Entities = new() { AmbientLight = Primary, Climate = Climate, Temperature = Temperature },
AdditionalSleepLights = [StripSettings()]
};
private static HomeAssistantState Light(string id, string state = "off", string[]? effects = null) => new()
{
EntityId = id, State = state, Attributes = JsonSerializer.SerializeToElement(new
{
supported_color_modes = new[] { "color_temp" }, min_color_temp_kelvin = 2700, max_color_temp_kelvin = 6500,
supported_features = 4, effect_list = effects ?? ["阅读模式", "助眠模式"], brightness = 128, color_temp_kelvin = 3000
})
};
private static HomeAssistantState ClimateState(string mode = "off") => new()
{
EntityId = Climate, State = mode, Attributes = JsonSerializer.SerializeToElement(new
{
hvac_modes = new[] { "heat", "cool", "off", "fan_only" }, supported_features = 393,
min_temp = 5, max_temp = 35, target_temp_step = .5, temperature_unit = "°C", temperature = 23,
current_temperature = 99 // 故意与环境仪不同,不能误用空调内置温度进行舒睡判断。
})
};
private sealed class Handler : ILightOffHandler
{
public int Calls;
public Task HandleAsync(CancellationToken token) { Calls++; return Task.CompletedTask; }
}
private sealed class Fixture
{
public ManualTestTimeProvider Time { get; } = new();
public FakeHaGateway Gateway { get; }
public IOptions<HomeAssistantOptions> Options { get; } = Microsoft.Extensions.Options.Options.Create(Settings());
public Handler Off { get; } = new();
public HomeAssistantLightTransitionService Lights { get; }
public DeviceActionRunner Runner { get; }
public DeviceModuleRegistry Modules { get; }
public HealthSceneCoordinator Coordinator { get; }
public Fixture()
{
Gateway = new() { Time = Time };
Gateway.States[Primary] = Light(Primary); Gateway.States[Strip] = Light(Strip);
Gateway.States[Climate] = ClimateState();
foreach (var id in new[] { "number.left_heat", "number.right_heat" }) Gateway.States[id] = new() { EntityId = id, State = "0" };
Runner = new(Gateway, Time, new() { VerificationDelay = TimeSpan.Zero });
var dispatcher = new LightOffDispatcher([Off]);
Lights = new(Gateway, dispatcher, Options, NullLogger<HomeAssistantLightTransitionService>.Instance, new(), Time);
var modules = new List<IHealthDeviceModule>
{
new LightingModule(Runner, Options, Lights.ForEntity(Primary), dispatcher),
new ConfiguredSleepLightModule(Runner, StripSettings(), Lights.ForEntity(Strip)),
new ClimateModule(Runner, Options)
};
Modules = new(modules);
Coordinator = new(Modules, Runner, Gateway, Lights, new FakeMusic());
}
public PreparedSleepScene Prepare(double? temperature)
{
if (temperature is not null) Gateway.States[Temperature] = new()
{
EntityId = Temperature, State = temperature.Value.ToString(System.Globalization.CultureInfo.InvariantCulture),
LastReported = Time.GetUtcNow(), Attributes = JsonSerializer.SerializeToElement(new { unit_of_measurement = "°C" })
};
else Gateway.States.TryRemove(Temperature, out _);
var comfort = new EnvironmentAdviceService(Gateway, new IndoorEnvironmentReader(Options, Time), Options)
.Get(null, Gateway.States);
var config = new PersonalizedSceneConfig { LightColorTemperatureKelvin = 3000 };
EnvironmentAdviceService.ApplyTo(config, comfort);
RoomTemperaturePolicy.PickTargetForSession(config.ClimateAdvice);
return new(config, "balanced");
}
}
private static async Task WaitUntilAsync(Func<bool> predicate)
{
for (var attempt = 0; attempt < 1000 && !predicate(); attempt++) await Task.Delay(1);
Assert.True(predicate(), "模拟渐变任务未按时推进");
}
private static async Task<T> CompleteAsync<T>(Task<T> task, ManualTestTimeProvider time)
{
for (var attempt = 0; attempt < 1000 && !task.IsCompleted; attempt++)
{
time.Advance(TimeSpan.FromMilliseconds(100)); await Task.Delay(1);
}
Assert.True(task.IsCompleted, "场景未在模拟时间中完成");
return await task;
}
[Fact]
public async Task SceneStartsBothLightsAtFiftyAndSetsOnlyStripsOwnSleepEffect()
{
var f = new Fixture();
try
{
var result = await f.Coordinator.ExecuteAsync("sleep", f.Prepare(24).Config, "balanced", default);
Assert.True(result.Success);
Assert.Equal(new[] { Primary, Strip }, f.Gateway.Calls.Where(call => call.Domain == "light").Select(call => call.EntityId));
var strip = f.Gateway.Calls.Single(call => call.EntityId == Strip);
Assert.Equal("助眠模式", strip.Data!["effect"]); Assert.Equal(50, strip.Data["brightness_pct"]);
Assert.Equal(3000, strip.Data["color_temp_kelvin"]);
Assert.False(f.Gateway.Calls.Single(call => call.EntityId == Primary).Data!.ContainsKey("effect"));
f.Time.Advance(TimeSpan.FromSeconds(5));
await WaitUntilAsync(() => f.Lights.GetStatus().Lights.All(light => light.BrightnessPercent == 49));
Assert.Equal(2, f.Lights.GetStatus().Lights.Count);
Assert.All(f.Lights.GetStatus().Lights, light => Assert.True(light.Running));
}
finally { await f.Lights.StopAsync(default); }
}
[Fact]
public async Task BothLightsReachZeroAndOnlyOriginalLightKeepsLightOffEvent()
{
var f = new Fixture();
try
{
await f.Coordinator.ExecuteAsync("sleep", f.Prepare(24).Config, "balanced", default);
for (var step = 1; step <= 50; step++)
{
f.Time.Advance(TimeSpan.FromSeconds(5));
var expected = 50 - step;
await WaitUntilAsync(() => f.Lights.GetStatus().Lights.All(light => light.BrightnessPercent == expected &&
(expected != 0 || !light.Running)));
}
Assert.Equal("off", f.Gateway.States[Primary].State); Assert.Equal("off", f.Gateway.States[Strip].State);
Assert.Equal(1, f.Off.Calls);
}
finally { await f.Lights.StopAsync(default); }
}
[Fact]
public async Task AllOffCancelsBothTimersAndShutsClimateWithoutLateReopening()
{
var f = new Fixture();
try
{
await f.Coordinator.ExecuteAsync("sleep", f.Prepare(30).Config, "balanced", default);
var result = await f.Coordinator.ExecuteAsync("all-off", null, "", default);
Assert.True(result.Success);
Assert.All(new[] { Primary, Strip, Climate }, id => Assert.Equal("off", f.Gateway.States[id].State));
Assert.All(f.Lights.GetStatus().Lights, light => Assert.False(light.Running));
var count = f.Gateway.Calls.Count; f.Time.Advance(TimeSpan.FromMinutes(5)); await Task.Delay(5);
Assert.Equal(count, f.Gateway.Calls.Count);
}
finally { await f.Lights.StopAsync(default); }
}
[Theory]
[InlineData(15, "heat")]
[InlineData(30, "cool")]
[InlineData(22, "off")]
[InlineData(24, "off")]
[InlineData(25, "off")]
public async Task EnvironmentSensorDrivesActualSceneClimateWithoutRetiredHeating(double room, string mode)
{
var f = new Fixture();
try
{
var prepared = f.Prepare(room);
var result = await CompleteAsync(f.Coordinator.ExecutePreparedSleepAsync(_ => Task.FromResult(prepared), default), f.Time);
Assert.True(result.Success); Assert.Equal(mode, result.ClimateAdvice!.Mode);
Assert.False(result.ClimateAdvice.PendingIntegration);
Assert.DoesNotContain(f.Gateway.Calls, call => call.EntityId is "number.left_heat" or "number.right_heat");
if (mode == "off")
{
Assert.Null(result.ClimateAdvice.TargetTemperatureCelsius);
Assert.DoesNotContain(f.Gateway.Calls, call => call.EntityId == Climate);
}
else
{
Assert.Equal(mode, f.Gateway.States[Climate].State);
Assert.InRange(result.ClimateAdvice.TargetTemperatureCelsius!.Value, 22, 25);
Assert.Equal(result.ClimateAdvice.TargetTemperatureCelsius.Value, f.Gateway.States[Climate].GetDoubleAttribute("temperature"));
Assert.Equal(new[] { "set_hvac_mode", "set_temperature" }, f.Gateway.Calls.Where(call => call.EntityId == Climate).Select(call => call.Service));
}
}
finally { await f.Lights.StopAsync(default); }
}
[Fact]
public async Task MissingOrStaleEnvironmentNeverStartsClimateOrHeating()
{
var f = new Fixture();
try
{
var prepared = f.Prepare(null);
var result = await f.Coordinator.ExecuteAsync("sleep", prepared.Config, "balanced", default);
Assert.Equal("unavailable", result.ClimateAdvice!.Mode);
Assert.DoesNotContain(f.Gateway.Calls, call => call.EntityId == Climate);
f.Prepare(15); f.Time.Advance(TimeSpan.FromMinutes(31));
var comfort = new EnvironmentAdviceService(f.Gateway, new IndoorEnvironmentReader(f.Options, f.Time), f.Options).Get(null, f.Gateway.States);
Assert.Equal("unavailable", comfort.ClimateAdvice.Mode);
}
finally { await f.Lights.StopAsync(default); }
}
[Theory]
[InlineData(true)]
[InlineData(false)]
public async Task ManualStandardControlCancelsOnlyThatLightsFade(bool strip)
{
var f = new Fixture();
try
{
await f.Coordinator.ExecuteAsync("sleep", f.Prepare(24).Config, "balanced", default);
var id = strip ? Strip : Primary;
var other = strip ? Primary : Strip;
var device = new HomeAssistantDiscoveredDeviceOutput
{
DeviceId = "manual", Online = true, PrimaryControlEntityId = id,
Entities = [new() { EntityId = id, Domain = "light", State = "on", Available = true }]
};
var parameters = new DeviceParameterController(f.Modules, f.Runner, [new StandardLightControl()]);
await parameters.ExecuteAsync(device, f.Gateway.States, "light_brightness", 77, id, default);
Assert.False(f.Lights.ForEntity(id).GetStatus().Running); Assert.True(f.Lights.ForEntity(other).GetStatus().Running);
f.Time.Advance(TimeSpan.FromSeconds(5));
await WaitUntilAsync(() => f.Lights.ForEntity(other).GetStatus().BrightnessPercent == 49);
Assert.Equal(77d / 100 * 255, f.Gateway.States[id].GetDoubleAttribute("brightness"));
}
finally { await f.Lights.StopAsync(default); }
}
[Theory]
[InlineData(Primary, Strip)]
[InlineData(Strip, Primary)]
public async Task OfflineLightDoesNotBlockOtherSceneLight(string offline, string active)
{
var f = new Fixture(); f.Gateway.States[offline] = Light(offline, "unavailable");
try
{
var result = await f.Coordinator.ExecuteAsync("sleep", f.Prepare(24).Config, "balanced", default);
Assert.True(result.Success); Assert.True(f.Lights.ForEntity(active).GetStatus().Running);
Assert.DoesNotContain(f.Gateway.Calls, call => call.EntityId == offline);
Assert.Contains(result.Actions, action => action.EntityId == offline && action.Status == "Skipped");
}
finally { await f.Lights.StopAsync(default); }
}
[Fact]
public async Task UnsupportedDefaultEffectFailsBeforeStripTurnsOnAndDoesNotStopOtherLight()
{
var f = new Fixture(); f.Gateway.States[Strip] = Light(Strip, effects: ["阅读模式"]);
try
{
var result = await f.Coordinator.ExecuteAsync("sleep", f.Prepare(24).Config, "balanced", default);
Assert.Equal("Partial", result.Status);
Assert.True(f.Lights.ForEntity(Primary).GetStatus().Running);
Assert.DoesNotContain(f.Gateway.Calls, call => call.EntityId == Strip);
Assert.Contains(result.Actions, action => action.EntityId == Strip && action.Status == "Failed");
}
finally { await f.Lights.StopAsync(default); }
}
[Fact]
public void ModuleDiscoveryAndPreviewExposeOwnBindingAndDefaultWithoutHardwareWrites()
{
var f = new Fixture();
var catalog = new HomeAssistantDeviceCatalog(f.Options, f.Modules);
var discovered = catalog.BuildDiscoveredDevices(null, f.Gateway.States);
var strip = Assert.Single(discovered.Where(device => device.PrimaryControlEntityId == Strip));
Assert.Equal("sleep-light-strip", strip.ModuleKey); Assert.Equal("助眠模式", strip.DefaultLightEffect);
Assert.Null(discovered.Single(device => device.PrimaryControlEntityId == Primary).DefaultLightEffect);
var preview = f.Modules.ForEntities([Strip])!.BuildPreview(new(), "balanced")!;
Assert.Equal(Strip, preview.EntityId); Assert.Contains("每5秒降低1%", preview.Detail);
Assert.Empty(f.Gateway.Calls);
}
[Fact]
public async Task ProductionRegistrationAddsSceneStripWithIndependentManualPower()
{
var services = new ServiceCollection(); services.AddOptions(); services.AddLogging(); services.AddHttpClient();
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>();
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]
public void DuplicateSceneLightEntityCannotCreateCompetingTimers()
{
var options = Settings(); options.AdditionalSleepLights.Add(new() { Key = "duplicate", Name = "重复灯", EntityId = Strip });
Assert.Throws<InvalidOperationException>(() => new HomeAssistantLightTransitionService(new FakeHaGateway(), new([]),
Options.Create(options), NullLogger<HomeAssistantLightTransitionService>.Instance, new(), TimeProvider.System));
}
}
@@ -0,0 +1,258 @@
using Evergreen.Health.Application.Health.Devices;
using Evergreen.Health.Application.Health.Devices.Contracts;
using Evergreen.Health.Application.Health.Devices.Discovery;
using Evergreen.Health.Application.Health.Devices.Lighting;
using Evergreen.Health.Application.Health.Integrations.HomeAssistant;
using Evergreen.Health.Application.Health.Models;
using Evergreen.Health.Application.Health.Scenes;
using Microsoft.Extensions.Options;
using Newtonsoft.Json.Linq;
using System.Text.Json;
using Xunit;
namespace Admin.NET.Test.Health;
/// <summary>标准能力、实例隔离和专属钩子全用假网关验证,不操作真实灯具。</summary>
public sealed class StandardLightControlTests
{
private const string LightA = "light.test_a", LightB = "light.test_b";
private static HomeAssistantState State(string id = LightA, string state = "on", string mode = "color_temp",
int min = 2700, int max = 6500, string effect = "阅读") => new()
{
EntityId = id, State = state, Attributes = JsonSerializer.SerializeToElement(new
{
supported_color_modes = new[] { mode }, min_color_temp_kelvin = min, max_color_temp_kelvin = max,
supported_features = 4, effect_list = new[] { effect, "日光" }, brightness = 128, color_temp_kelvin = 3000,
effect, internal_config = 842138120
})
};
private static HomeAssistantDiscoveredDeviceOutput Device(string id = LightA) => new()
{
DeviceId = id, PrimaryControlEntityId = id, Online = true,
Entities = [new() { EntityId = id, Domain = "light", State = "on", Available = true }]
};
private static DeviceActionRunner Runner(IHomeAssistantGateway gateway) => new(gateway, TimeProvider.System,
new() { VerificationDelay = TimeSpan.Zero });
private static DeviceParameterController Controller(FakeHaGateway gateway, params IHealthDeviceModule[] modules)
=> new(new DeviceModuleRegistry(modules), Runner(gateway), [new StandardLightControl()]);
[Theory]
[InlineData("light_brightness", "0", "turn_off", null)]
[InlineData("light_brightness", "50", "turn_on", "brightness_pct")]
[InlineData("light_brightness", "100", "turn_on", "brightness_pct")]
[InlineData("light_color_temperature", "2700", "turn_on", "color_temp_kelvin")]
[InlineData("light_color_temperature", "6500", "turn_on", "color_temp_kelvin")]
[InlineData("light_effect", "阅读", "turn_on", "effect")]
public async Task NewLightNeedsNoModelModuleAndUsesOnlySupportedStandardServices(string parameter, string value, string service, string? field)
{
var gateway = new FakeHaGateway(); gateway.States[LightA] = State(state: "off");
await Controller(gateway).ExecuteAsync(Device(), gateway.States, parameter, value, LightA, default);
var call = Assert.Single(gateway.Calls);
Assert.Equal("light", call.Domain); Assert.Equal(service, call.Service); Assert.Equal(LightA, call.EntityId);
Assert.Equal(LightA, call.Data!["entity_id"]);
if (field is null) Assert.Single(call.Data);
else { Assert.Equal(2, call.Data.Count); Assert.Equal(value, call.Data[field]!.ToString()); }
}
[Theory]
[InlineData("light_brightness", "-1")]
[InlineData("light_brightness", "101")]
[InlineData("light_brightness", "10.5")]
[InlineData("light_color_temperature", "2200")]
[InlineData("light_color_temperature", "6501")]
[InlineData("light_color_temperature", "3000.5")]
[InlineData("light_effect", "删除所有遥控器")]
[InlineData("button.press", "anything")]
public async Task InvalidOrEngineeringParametersDoNotSendAnyCommand(string parameter, string value)
{
var gateway = new FakeHaGateway(); gateway.States[LightA] = State();
await Assert.ThrowsAsync<InvalidOperationException>(() => Controller(gateway).ExecuteAsync(
Device(), gateway.States, parameter, value, null, default));
Assert.Empty(gateway.Calls);
}
[Theory]
[InlineData("onoff")]
[InlineData("unknown")]
public async Task OnOffOnlyOrUnknownModeDoesNotReceiveBrightnessOrColorTemperature(string mode)
{
var gateway = new FakeHaGateway(); gateway.States[LightA] = State(mode: mode);
var controller = Controller(gateway);
await Assert.ThrowsAsync<InvalidOperationException>(() => controller.ExecuteAsync(Device(), gateway.States, "light_brightness", 50, null, default));
await Assert.ThrowsAsync<InvalidOperationException>(() => controller.ExecuteAsync(Device(), gateway.States, "light_color_temperature", 3000, null, default));
Assert.Empty(gateway.Calls);
}
[Theory]
[InlineData("brightness")]
[InlineData("rgb")]
[InlineData("rgbww")]
public async Task DimmableLightWithoutColorTemperatureDoesNotInheritAnotherLampsTemperature(string mode)
{
var gateway = new FakeHaGateway(); gateway.States[LightA] = State(mode: mode);
var controller = Controller(gateway);
await controller.ExecuteAsync(Device(), gateway.States, "light_brightness", 50, null, default);
await Assert.ThrowsAsync<InvalidOperationException>(() => controller.ExecuteAsync(Device(), gateway.States, "light_color_temperature", 3000, null, default));
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()
{
var gateway = new FakeHaGateway();
gateway.States[LightA] = State(min: 2200, max: 4500, effect: "阅读");
gateway.States[LightB] = State(LightB, min: 3000, max: 6000, effect: "助眠");
var controller = Controller(gateway);
await controller.ExecuteAsync(Device(), gateway.States, "light_color_temperature", 2200, null, default);
await Assert.ThrowsAsync<InvalidOperationException>(() => controller.ExecuteAsync(Device(LightB), gateway.States, "light_color_temperature", 2200, null, default));
await Assert.ThrowsAsync<InvalidOperationException>(() => controller.ExecuteAsync(Device(LightB), gateway.States, "light_effect", "阅读", null, default));
await controller.ExecuteAsync(Device(LightB), gateway.States, "light_effect", "助眠", null, default);
Assert.Equal(new[] { LightA, LightB }, gateway.Calls.Select(call => call.EntityId));
}
[Theory]
[InlineData("unknown")]
[InlineData("unavailable")]
[InlineData("")]
public async Task MainLightAvailabilityCannotBeBorrowedFromAnotherEntity(string state)
{
var gateway = new FakeHaGateway(); gateway.States[LightA] = State(state: state); gateway.States[LightB] = State(LightB);
var device = Device(); device.Entities.Add(new() { EntityId = LightB, Domain = "light", Available = true });
await Assert.ThrowsAsync<InvalidOperationException>(() => Controller(gateway).ExecuteAsync(device, gateway.States, "light_brightness", 50, null, default));
Assert.Empty(gateway.Calls);
}
[Fact]
public async Task ForeignEntityEvenAnotherLightOnSameDeviceCannotOverrideTrustedPrimary()
{
var gateway = new FakeHaGateway(); gateway.States[LightA] = State(); gateway.States[LightB] = State(LightB);
var device = Device(); device.Entities.Add(new() { EntityId = LightB, Domain = "light", Available = true });
await Assert.ThrowsAsync<InvalidOperationException>(() => Controller(gateway).ExecuteAsync(device, gateway.States, "light_brightness", 50, LightB, default));
device.Entities.RemoveAt(0);
await Assert.ThrowsAsync<InvalidOperationException>(() => Controller(gateway).ExecuteAsync(device, gateway.States, "light_brightness", 50, null, default));
device = Device(); device.Entities[0].Domain = "switch";
await Assert.ThrowsAsync<InvalidOperationException>(() => Controller(gateway).ExecuteAsync(device, gateway.States, "light_brightness", 50, null, default));
Assert.Empty(gateway.Calls);
}
[Fact]
public async Task CapabilityChangeAfterRequestSnapshotIsRecheckedBeforeSend()
{
var gateway = new FakeHaGateway(); gateway.States[LightA] = State(mode: "onoff");
var snapshot = new Dictionary<string, HomeAssistantState> { [LightA] = State() };
await Assert.ThrowsAsync<InvalidOperationException>(() => Controller(gateway).ExecuteAsync(Device(), snapshot, "light_brightness", 50, null, default));
Assert.Empty(gateway.Calls);
}
[Fact]
public async Task MissingRangeOrEffectFlagDoesNotEnableTheseControls()
{
var gateway = new FakeHaGateway();
gateway.States[LightA] = new() { EntityId = LightA, State = "on", Attributes = JsonSerializer.SerializeToElement(new
{ supported_color_modes = new[] { "color_temp" }, effect_list = new[] { "阅读" }, supported_features = 0 }) };
var controller = Controller(gateway);
await Assert.ThrowsAsync<InvalidOperationException>(() => controller.ExecuteAsync(Device(), gateway.States, "light_color_temperature", 3000, null, default));
await Assert.ThrowsAsync<InvalidOperationException>(() => controller.ExecuteAsync(Device(), gateway.States, "light_effect", "阅读", null, default));
Assert.Empty(gateway.Calls);
}
[Fact]
public async Task CancellationPreventsStandardWrite()
{
var gateway = new FakeHaGateway(); gateway.States[LightA] = State();
using var cancellation = new CancellationTokenSource(); cancellation.Cancel();
await Assert.ThrowsAnyAsync<OperationCanceledException>(() => Controller(gateway).ExecuteAsync(
Device(), gateway.States, "light_brightness", 50, null, cancellation.Token));
Assert.Empty(gateway.Calls);
}
[Fact]
public async Task BoundSceneLightKeepsItsHooksButNewLightDoesNotTriggerThem()
{
var gateway = new FakeHaGateway(); gateway.States[LightA] = State(); gateway.States[LightB] = State(LightB);
var runtime = new CountingLight(); var handler = new CountingOffHandler();
var module = new LightingModule(Runner(gateway), Options.Create(new HomeAssistantOptions { Entities = new() { AmbientLight = LightA } }),
runtime, new LightOffDispatcher([handler]));
var controller = Controller(gateway, module);
await controller.ExecuteAsync(Device(LightB), gateway.States, "light_brightness", 0, null, default);
Assert.Equal(0, runtime.Cancels); Assert.Equal(0, handler.Calls);
await controller.ExecuteAsync(Device(), gateway.States, "light_brightness", 0, null, default);
Assert.Equal(1, runtime.Cancels); Assert.Equal(1, handler.Calls);
Assert.Empty(gateway.Calls.Where(call => call.EntityId is not (LightA or LightB)));
}
[Fact]
public async Task ExistingSpecialParametersStillRouteOnlyToTheirOwningModule()
{
var gateway = new FakeHaGateway(); gateway.States["number.synthetic"] = new() { EntityId = "number.synthetic", State = "0" };
var module = new SyntheticDeviceModule(Runner(gateway));
var device = new HomeAssistantDiscoveredDeviceOutput { DeviceId = "special", Entities = [new() { EntityId = "number.synthetic", Domain = "number" }] };
await Controller(gateway, module).ExecuteAsync(device, gateway.States, "synthetic_value", 3, null, default);
Assert.Equal("number.synthetic", Assert.Single(gateway.Calls).EntityId);
await Assert.ThrowsAsync<InvalidOperationException>(() => Controller(gateway, module).ExecuteAsync(Device(), gateway.States, "synthetic_value", 3, null, default));
Assert.Single(gateway.Calls);
}
[Fact]
public void LightCapabilitiesSurviveActualApiSerializerWithoutExposingPackedInternalFields()
{
var safe = HomeAssistantDeviceCatalog.ExtractSafeAttributes(State())!;
var json = JObject.Parse(Newtonsoft.Json.JsonConvert.SerializeObject(safe));
Assert.Equal(2700, json["min_color_temp_kelvin"]!.Value<int>());
Assert.Equal(6500, json["max_color_temp_kelvin"]!.Value<int>());
Assert.Equal("color_temp", json["supported_color_modes"]![0]!.Value<string>());
Assert.Equal("阅读", json["effect_list"]![0]!.Value<string>());
Assert.Null(json["internal_config"]);
}
private sealed class CountingOffHandler : ILightOffHandler
{
public int Calls;
public Task HandleAsync(CancellationToken ct) { ct.ThrowIfCancellationRequested(); Calls++; return Task.CompletedTask; }
}
private sealed class CountingLight : ILightTransitionRuntime
{
public int Cancels;
public HomeAssistantLightTransitionOutput GetStatus() => new();
public Task<HomeAssistantLightTransitionOutput> StartSleepAsync(int kelvin, CancellationToken ct) => Task.FromResult(new HomeAssistantLightTransitionOutput());
public Task<HomeAssistantLightTransitionOutput> StartWakeAsync(CancellationToken ct) => Task.FromResult(new HomeAssistantLightTransitionOutput());
public Task<HomeAssistantLightTransitionOutput> CancelAsync(CancellationToken ct) { Cancels++; return Task.FromResult(new HomeAssistantLightTransitionOutput()); }
}
}
@@ -0,0 +1,116 @@
using Evergreen.Health.Application.Health.Devices;
using Evergreen.Health.Application.Health.Devices.Discovery;
using Evergreen.Health.Application.Health.Devices.Lighting;
using Evergreen.Health.Application.Health.Integrations.HomeAssistant;
using Evergreen.Health.Application.Health.Scenes;
using Microsoft.Extensions.Options;
using System.Text.Json;
using Xunit;
namespace Admin.NET.Test.Health;
public sealed class SystemServiceFilteringTests
{
private static HomeAssistantDeviceCatalog Catalog() => new(Options.Create(new HomeAssistantOptions()), new DeviceModuleRegistry([]));
[Fact]
public void RegistryDeserializationRetainsSystemServiceMarker()
{
var device = JsonSerializer.Deserialize<HomeAssistantDeviceRegistryItem>(
"""{"id":"system-sun","name":"Sun","entry_type":"service"}""");
Assert.NotNull(device);
Assert.Equal("service", device.EntryType);
}
[Fact]
public void SystemServicesAreExcludedFromListAndAllDeviceCounts()
{
var registry = new HomeAssistantRegistries
{
Devices = [new() { Id = "sun", Name = "Sun", EntryType = "service" },
new() { Id = "backup", Name = "Backup", EntryType = "service" }],
Entities = [new() { DeviceId = "sun", EntityId = "sensor.sun_next_rising", Platform = "sun" },
new() { DeviceId = "backup", EntityId = "sensor.backup_backup_manager_state", Platform = "backup" }]
};
var states = new Dictionary<string, HomeAssistantState>
{
["sensor.sun_next_rising"] = new() { EntityId = "sensor.sun_next_rising", State = "2026-10-10T06:00:00Z" },
["sensor.backup_backup_manager_state"] = new() { EntityId = "sensor.backup_backup_manager_state", State = "idle" }
};
for (var i = 0; i < 9; i++)
{
var deviceId = $"furniture-{i}";
var entityId = i == 0 ? "cover.curtain" : $"switch.furniture_{i}";
registry.Devices.Add(new() { Id = deviceId, Name = $"家具{i}" });
registry.Entities.Add(new() { DeviceId = deviceId, EntityId = entityId, Platform = "xiaomi_home" });
states[entityId] = new() { EntityId = entityId, State = i == 0 ? "unavailable" : "on" };
}
var devices = Catalog().BuildDiscoveredDevices(registry, states);
Assert.Equal(9, devices.Count);
Assert.DoesNotContain(devices, device => device.DeviceId is "sun" or "backup");
Assert.Contains(devices, device => device.DeviceId == "furniture-0" && !device.Online);
var summary = HomeAssistantDeviceCatalog.BuildSummary(devices);
Assert.Equal(9, summary.TotalDevices);
Assert.Equal(8, summary.OnlineDevices);
Assert.Equal(1, summary.OfflineDevices);
Assert.Equal(9, summary.TotalEntities);
Assert.Equal(8, summary.AvailableEntities);
}
[Theory]
[InlineData("service")]
[InlineData("SERVICE")]
public void RenamedSystemServiceStillDoesNotBecomeFurniture(string entryType)
{
var registry = new HomeAssistantRegistries
{
Devices = [new() { Id = "service", Name = "系统服务", NameByUser = "我的灯", EntryType = entryType }],
Entities = [new() { DeviceId = "service", EntityId = "sensor.service" }]
};
Assert.Empty(Catalog().BuildDiscoveredDevices(registry, new Dictionary<string, HomeAssistantState>
{
["sensor.service"] = new() { EntityId = "sensor.service", State = "on" }
}));
}
[Theory]
[InlineData("Sun", null)]
[InlineData("Backup", "")]
public void PhysicalDevicesAreNotHiddenJustBecauseTheyShareServiceNames(string name, string? entryType)
{
var registry = new HomeAssistantRegistries
{
Devices = [new() { Id = "physical", Name = name, EntryType = entryType, Manufacturer = "Other brand" }],
Entities = [new() { DeviceId = "physical", EntityId = "sensor.physical", Platform = "other_integration" }]
};
var device = Assert.Single(Catalog().BuildDiscoveredDevices(registry, new Dictionary<string, HomeAssistantState>
{
["sensor.physical"] = new() { EntityId = "sensor.physical", State = "20" }
}));
Assert.Equal(name, device.Name);
Assert.True(device.Online);
}
[Fact]
public void ValidServiceOnlyRegistryDoesNotCreateFallbackFurniture()
{
var options = Options.Create(new HomeAssistantOptions
{
Entities = new() { AmbientLight = "light.bedroom" }
});
var runner = new DeviceActionRunner(new FakeHaGateway(), TimeProvider.System, new());
var catalog = new HomeAssistantDeviceCatalog(options, new DeviceModuleRegistry([new LightingModule(runner, options, new FakeLight(), new LightOffDispatcher([]))]));
var registry = new HomeAssistantRegistries
{
Devices = [new() { Id = "backup", Name = "Backup", EntryType = "service" }],
Entities = [new() { DeviceId = "backup", EntityId = "sensor.backup", Platform = "backup" }]
};
var states = new Dictionary<string, HomeAssistantState>
{
["sensor.backup"] = new() { EntityId = "sensor.backup", State = "idle" },
["light.bedroom"] = new() { EntityId = "light.bedroom", State = "0" }
};
Assert.Empty(catalog.BuildDiscoveredDevices(registry, states));
Assert.Equal("lighting", Assert.Single(catalog.BuildDiscoveredDevices(null, states)).ModuleKey);
}
}
+3
View File
@@ -7,6 +7,7 @@
using Admin.NET.Core; using Admin.NET.Core;
using AspNetCoreRateLimit; using AspNetCoreRateLimit;
using Evergreen.Health.Application.Health.Integrations.HomeAssistant; using Evergreen.Health.Application.Health.Integrations.HomeAssistant;
using Evergreen.Health.Application.Health.Integrations.MiAirX;
using Furion; using Furion;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
@@ -38,6 +39,8 @@ public static class ProjectOptions
services.AddConfigurableOptions<DeepSeekOptions>(); services.AddConfigurableOptions<DeepSeekOptions>();
services.AddConfigurableOptions<LocalizationSettingsOptions>(); services.AddConfigurableOptions<LocalizationSettingsOptions>();
services.AddConfigurableOptions<HomeAssistantOptions>(); services.AddConfigurableOptions<HomeAssistantOptions>();
services.AddConfigurableOptions<MiAirXOptions>();
services.AddConfigurableOptions<TcmMusicOptions>();
services.Configure<IpRateLimitOptions>(App.Configuration.GetSection("IpRateLimiting")); services.Configure<IpRateLimitOptions>(App.Configuration.GetSection("IpRateLimiting"));
services.Configure<IpRateLimitPolicies>(App.Configuration.GetSection("IpRateLimitPolicies")); services.Configure<IpRateLimitPolicies>(App.Configuration.GetSection("IpRateLimitPolicies"));
services.Configure<ClientRateLimitOptions>(App.Configuration.GetSection("ClientRateLimiting")); services.Configure<ClientRateLimitOptions>(App.Configuration.GetSection("ClientRateLimiting"));
+32 -5
View File
@@ -9,6 +9,10 @@ using Admin.NET.Core.ElasticSearch;
using Admin.NET.Core.Service; using Admin.NET.Core.Service;
using AspNetCoreRateLimit; using AspNetCoreRateLimit;
using Evergreen.Health.Application.Health.Integrations.HomeAssistant; using Evergreen.Health.Application.Health.Integrations.HomeAssistant;
using Evergreen.Health.Application.Health.Devices;
using Evergreen.Health.Application.Health.Integrations.MiAirX;
using Evergreen.Health.Application.Health.Integrations.Ib43;
using Evergreen.Health.Application.Health.Sleep;
using Furion; using Furion;
using Furion.Logging; using Furion.Logging;
using Furion.SpecificationDocument; using Furion.SpecificationDocument;
@@ -16,12 +20,15 @@ using Furion.VirtualFileServer;
using IPTools.Core; using IPTools.Core;
using Microsoft.AspNetCore.Authentication.JwtBearer; using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Diagnostics;
using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.HttpOverrides; using Microsoft.AspNetCore.HttpOverrides;
using Microsoft.AspNetCore.ResponseCompression; using Microsoft.AspNetCore.ResponseCompression;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Newtonsoft.Json; using Newtonsoft.Json;
using OnceMi.AspNetCore.OSS; using OnceMi.AspNetCore.OSS;
using Scalar.AspNetCore; using Scalar.AspNetCore;
@@ -76,6 +83,14 @@ public class Startup : AppStartup
// 远程请求 // 远程请求
services.AddHttpRemote(); services.AddHttpRemote();
services.AddHttpClient<HomeAssistantClient>(); services.AddHttpClient<HomeAssistantClient>();
services.AddHttpClient<MiAirXClient>();
services.AddHealthDevices();
services.AddOptions<Ib43Options>().BindConfiguration("Ib43")
.PostConfigure(Ib43Configuration.ApplyEnvironmentOverrides)
.Validate(options => options.IsValid(), "IB43 enabled configuration requires credentials and valid unique device bindings")
.ValidateOnStart();
services.AddOptions<BedPresenceAutomationOptions>().BindConfiguration("BedPresenceAutomation")
.PostConfigure(options => new ConfigurationBuilder().AddEnvironmentVariables("BedPresenceAutomation__").Build().Bind(options));
// 任务队列 // 任务队列
services.AddTaskQueue(); services.AddTaskQueue();
// 脱敏检测 // 脱敏检测
@@ -98,7 +113,8 @@ public class Startup : AppStartup
} }
; ;
services.AddControllersWithViews() // 后端只提供 API;移动端页面位于独立的 Mobile 项目。
services.AddControllers()
.AddAppLocalization() .AddAppLocalization()
.AddNewtonsoftJson(options => SetNewtonsoftJsonSetting(options.SerializerSettings)) .AddNewtonsoftJson(options => SetNewtonsoftJsonSetting(options.SerializerSettings))
//.AddXmlSerializerFormatters() //.AddXmlSerializerFormatters()
@@ -281,7 +297,19 @@ public class Startup : AppStartup
} }
else else
{ {
app.UseExceptionHandler("/Home/Error"); // API 未处理异常保持 JSON 响应;原 /Home/Error 没有对应处理入口。
app.UseExceptionHandler(errorApp => errorApp.Run(async context =>
{
var exception = context.Features.Get<IExceptionHandlerFeature>()?.Error;
context.RequestServices.GetRequiredService<ILogger<Startup>>()
.LogError(exception, "请求发生未处理异常,TraceId={TraceId}", context.TraceIdentifier);
context.Response.StatusCode = StatusCodes.Status500InternalServerError;
await context.Response.WriteAsJsonAsync(new
{
code = 500, type = "error", message = "服务暂时不可用,请稍后重试",
result = (object)null, extras = new { traceId = context.TraceIdentifier }
}, context.RequestAborted);
}));
app.UseHsts(); app.UseHsts();
} }
@@ -367,9 +395,8 @@ public class Startup : AppStartup
// 注册集线器 // 注册集线器
endpoints.MapHubs(); endpoints.MapHubs();
endpoints.MapControllerRoute( // 健康接口和后台接口均使用特性路由,不再保留默认欢迎页。
name: "default", endpoints.MapControllers();
pattern: "{controller=Home}/{action=Index}/{id?}");
}); });
} }
} }
@@ -1,29 +0,0 @@
// Admin.NET 项目的版权、商标、专利和其他相关权利均受相应法律法规的保护。使用本项目应遵守相关法律法规和许可证的要求。
//
// 本项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。许可证位于源代码树根目录中的 LICENSE-MIT 和 LICENSE-APACHE 文件。
//
// 不得利用本项目从事危害国家安全、扰乱社会秩序、侵犯他人合法权益等法律法规禁止的活动!任何基于本项目二次开发而产生的一切法律纠纷和责任,我们不承担任何责任!
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace Evergreen.Health.Api.Controllers
{
[AllowAnonymous]
public class HomeController : Controller
{
//private readonly ISystemService _systemService;
//public HomeController(ISystemService systemService)
//{
// _systemService = systemService;
//}
public IActionResult Index()
{
//ViewBag.Description = _systemService.GetDescription();
return View();
}
}
}
+3 -1
View File
@@ -13,7 +13,9 @@ public class WebComponent : IWebComponent
// 设置日志过滤 // 设置日志过滤
builder.Logging.AddFilter((provider, category, logLevel) => builder.Logging.AddFilter((provider, category, logLevel) =>
{ {
return !new[] { "Microsoft.Hosting", "Microsoft.AspNetCore" }.Any(u => category.StartsWith(u)) && logLevel >= LogLevel.Information; var frameworkNoise = new[] { "Microsoft.Hosting", "Microsoft.AspNetCore" }
.Any(prefix => category?.StartsWith(prefix, StringComparison.Ordinal) == true);
return logLevel >= LogLevel.Warning || (!frameworkNoise && logLevel >= LogLevel.Information);
}); });
// 设置接口超时时间和上传大小-Kestrel // 设置接口超时时间和上传大小-Kestrel
@@ -1,7 +0,0 @@
@{
ViewData["Title"] = ViewBag.Description;
}
<div style="text-align:center;margin-top:50px;">
<img src="~/images/logo.png" style="height:100px;" />
</div>
@@ -1,11 +0,0 @@
<!DOCTYPE html>
<html lang="zh-cmn-Hans">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>@ViewData["Title"] - Furion</title>
</head>
<body>
@RenderBody()
</body>
</html>
@@ -1,2 +0,0 @@
@using Evergreen.Health.Api
@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers
@@ -1,3 +0,0 @@
@{
Layout = "_Layout";
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.8 KiB

@@ -0,0 +1,77 @@
{
"BedPresenceAutomation": {
"Mode": "Managed",
"BindingId": "",
"ProductKey": "evergreen-test",
"SerialNumber": "IB43SIM001",
"Side": "Left",
"ActionKey": "curtain-close",
"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"
}
]
}
}
@@ -9,9 +9,24 @@
"DehumidifierUv": "switch.xiaomi_cn_972783525_60l_uv_p_2_10", "DehumidifierUv": "switch.xiaomi_cn_972783525_60l_uv_p_2_10",
"FragranceSwitch": "switch.xiaomi_cn_1206553658_xw002_on_p_2_2", "FragranceSwitch": "switch.xiaomi_cn_1206553658_xw002_on_p_2_2",
"FragranceLight": "light.xiaomi_cn_1206553658_xw002_s_3_ambient_light", "FragranceLight": "light.xiaomi_cn_1206553658_xw002_s_3_ambient_light",
"AmbientLight": "light.lemesh_cn_2041198316_wy0d02_s_2_light", "FragranceDuration": "number.xiaomi_cn_1206553658_xw002_fragrance_out_time_p_2_3",
"FragranceInterval": "number.xiaomi_cn_1206553658_xw002_fragrance_out_time_p_2_4",
"FragranceQuickRelease": "button.xiaomi_cn_1206553658_xw002_fragrance_delivery_a_4_1",
"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", "Speaker": "media_player.xiaomi_cn_1221084723_l17a",
"SpeakerSleepMode": "switch.xiaomi_cn_1221084723_l17a_sleep_mode_p_7_3", "SpeakerSleepMode": "switch.xiaomi_cn_1221084723_l17a_sleep_mode_p_7_3",
"Climate": "climate.fawad_cn_974720212_3010",
"Curtain": "cover.czmydz_cn_2251706154_mym1_s_2_curtain",
"Temperature": "sensor.mirsz_cn_2027039858_et71_temperature_p_3_1", "Temperature": "sensor.mirsz_cn_2027039858_et71_temperature_p_3_1",
"Humidity": "sensor.mirsz_cn_2027039858_et71_relative_humidity_p_3_2", "Humidity": "sensor.mirsz_cn_2027039858_et71_relative_humidity_p_3_2",
"Pm25": "sensor.mirsz_cn_2027039858_et71_pm2_5_density_p_3_3", "Pm25": "sensor.mirsz_cn_2027039858_et71_pm2_5_density_p_3_3",
@@ -19,15 +34,45 @@
"Formaldehyde": "sensor.mirsz_cn_2027039858_et71_hcho_density_p_3_7", "Formaldehyde": "sensor.mirsz_cn_2027039858_et71_hcho_density_p_3_7",
"Tvoc": "sensor.mirsz_cn_2027039858_et71_tvoc_density_p_3_8" "Tvoc": "sensor.mirsz_cn_2027039858_et71_tvoc_density_p_3_8"
}, },
"OnlyConfiguredDevices": true,
"ManualLightEntityIds": [],
"AdditionalSleepLights": [
{
"Key": "roof-light-strip-1",
"Name": "超薄智能灯带",
"EntityId": "light.hekai1_cn_2224036048_wy0a01_s_2_light",
"SleepEffect": "助眠模式",
"StartSleepOnManualPowerOn": false
}
],
"SleepScene": { "SleepScene": {
"TargetHumidity": 55, "TargetHumidity": 55,
"DehumidifierMode": "睡眠", "DehumidifierMode": "睡眠",
"FanLevel": "1挡", "FanLevel": "1挡",
"LightBrightnessPercent": 20,
"LightColorTemperatureKelvin": 2700,
"EnableFragrance": true, "EnableFragrance": true,
"EnableSpeakerSleepMode": false, "EnableSpeakerSleepMode": true,
"EnableUv": false "EnableUv": false
} },
"ExcludedDeviceEntityIds": [
"number.qushui_cn_2108064504_zerox_left_level_p_6_1",
"number.qushui_cn_2108064504_zerox_right_level_p_6_2",
"select.qushui_cn_2108064504_zerox_left_mode_p_5_1",
"select.qushui_cn_2108064504_zerox_right_mode_p_5_2",
"select.qushui_cn_2108064504_zerox_left_shoulder_level_p_5_3",
"select.qushui_cn_2108064504_zerox_right_shoulder_level_p_5_4",
"select.qushui_cn_2108064504_zerox_left_back_level_p_5_5",
"select.qushui_cn_2108064504_zerox_right_back_level_p_5_6",
"select.qushui_cn_2108064504_zerox_left_waist_level_p_5_7",
"select.qushui_cn_2108064504_zerox_right_waist_level_p_5_8",
"select.qushui_cn_2108064504_zerox_left_lumbar_level_p_5_9",
"select.qushui_cn_2108064504_zerox_right_lumbar_level_p_5_10",
"select.qushui_cn_2108064504_zerox_left_hip_level_p_5_11",
"select.qushui_cn_2108064504_zerox_right_hip_level_p_5_12",
"select.qushui_cn_2108064504_zerox_left_leg_level_p_5_13",
"select.qushui_cn_2108064504_zerox_right_leg_level_p_5_14",
"select.qushui_cn_2108064504_zerox_left_part_p_4_1",
"select.qushui_cn_2108064504_zerox_right_part_p_4_2",
"select.qushui_cn_2108064504_zerox_level_p_4_3"
]
} }
} }
@@ -0,0 +1,20 @@
{
"Ib43": {
"Enabled": false,
"Host": "192.168.2.2",
"Port": 1883,
"UseTls": false,
"Username": "evergreen-backend",
"Password": "",
"ClientId": "evergreen-health-backend",
"StaleAfterSeconds": 150,
"Devices": [
{
"ProductKey": "evergreen-test",
"SerialNumber": "IB43SIM001",
"Simulation": true,
"PersonLeftIndex": null
}
]
}
}
@@ -0,0 +1,35 @@
{
"TcmMusic": {
"StartVolume": 50,
"FadeStepSeconds": 5,
"Tracks": {
"balanced": [
{ "Title": "雨声", "FileName": "balanced_01_rain.mp3", "Url": "https://ziliaoku.cnzhyx.cn/App.Live/uploads/_OSS%E4%B8%8A%E4%BC%A0%E7%89%88/balanced_01_rain.mp3", "DurationSeconds": 20.2 }
],
"qi_deficient": [
{ "Title": "轻音乐", "FileName": "qi_deficient_01_light_music.mp3", "Url": "https://ziliaoku.cnzhyx.cn/App.Live/uploads/_OSS%E4%B8%8A%E4%BC%A0%E7%89%88/qi_deficient_01_light_music.mp3", "DurationSeconds": 115.1 }
],
"yang_deficient": [
{ "Title": "雨声", "FileName": "yang_deficient_01_rain.mp3", "Url": "https://ziliaoku.cnzhyx.cn/App.Live/uploads/_OSS%E4%B8%8A%E4%BC%A0%E7%89%88/yang_deficient_01_rain.mp3", "DurationSeconds": 20.2 }
],
"yin_deficient": [
{ "Title": "海浪白噪音", "FileName": "yin_deficient_01_ocean_waves.mp3", "Url": "https://ziliaoku.cnzhyx.cn/App.Live/uploads/_OSS%E4%B8%8A%E4%BC%A0%E7%89%88/yin_deficient_01_ocean_waves.mp3", "DurationSeconds": 86.5 }
],
"phlegm_damp": [
{ "Title": "规律鸟鸣白噪音", "FileName": "phlegm_damp_01_birds.mp3", "Url": "https://ziliaoku.cnzhyx.cn/App.Live/uploads/_OSS%E4%B8%8A%E4%BC%A0%E7%89%88/phlegm_damp_01_birds.mp3", "DurationSeconds": 294.0 }
],
"damp_heat": [
{ "Title": "自然流水声", "FileName": "damp_heat_01_stream.mp3", "Url": "https://ziliaoku.cnzhyx.cn/App.Live/uploads/_OSS%E4%B8%8A%E4%BC%A0%E7%89%88/damp_heat_01_stream.mp3", "DurationSeconds": 33.5 }
],
"blood_stasis": [
{ "Title": "低频舒缓音乐", "FileName": "blood_stasis_01_low_frequency.mp3", "Url": "https://ziliaoku.cnzhyx.cn/App.Live/uploads/_OSS%E4%B8%8A%E4%BC%A0%E7%89%88/blood_stasis_01_low_frequency.mp3", "DurationSeconds": 60.0 }
],
"qi_stagnation": [
{ "Title": "冥想古琴曲", "FileName": "qi_stagnation_01_guqin.mp3", "Url": "https://ziliaoku.cnzhyx.cn/App.Live/uploads/_OSS%E4%B8%8A%E4%BC%A0%E7%89%88/qi_stagnation_01_guqin.mp3", "DurationSeconds": 1242.1 }
],
"special": [
{ "Title": "田野风声", "FileName": "special_01_field_wind.mp3", "Url": "https://ziliaoku.cnzhyx.cn/App.Live/uploads/_OSS%E4%B8%8A%E4%BC%A0%E7%89%88/special_01_field_wind.mp3", "DurationSeconds": 48.6 }
]
}
}
}
@@ -25,6 +25,7 @@
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="MQTTnet" Version="4.3.7.1207" />
<ProjectReference Include="..\Admin.NET.Core\Admin.NET.Core.csproj" /> <ProjectReference Include="..\Admin.NET.Core\Admin.NET.Core.csproj" />
</ItemGroup> </ItemGroup>
@@ -0,0 +1,61 @@
using Evergreen.Health.Application.Health.Devices.Contracts;
using Evergreen.Health.Application.Health.Integrations.HomeAssistant;
using Evergreen.Health.Application.Health.Models;
using Evergreen.Health.Application.Health.Scenes;
using Microsoft.Extensions.Options;
using Evergreen.Health.Application.Health.Integrations.MiAirX;
namespace Evergreen.Health.Application.Health.Devices.Audio;
public sealed class AudioModule : HealthDeviceModule
{
private readonly HomeAssistantEntityOptions _entities;
private readonly ITcmMusicRuntime _music;
public AudioModule(DeviceActionRunner runner, IOptions<HomeAssistantOptions> options, ITcmMusicRuntime music) : base(runner)
{
_entities = options.Value.Entities; _music = music;
Parameters =
[
new("volume", PrimaryEntityId, value => new("设置音箱音量", PrimaryEntityId, "audio", "volume",
new Dictionary<string, object?> { ["volume"] = DeviceValues.Integer(value, 0, 100, "音箱音量") }),
"音箱音量", 0, 100),
DeviceValues.Switch("speaker_sleep_mode", _entities.SpeakerSleepMode, "switch", "音箱睡眠模式")
];
}
public override string Key => "audio";
public override string Category => nameof(HealthDeviceCategory.Media);
public override string DisplayName => "助眠音箱";
public override string PrimaryEntityId => _entities.Speaker;
public override bool PowerUsesIndependentGateway => true;
public override bool ParameterUsesIndependentGateway(DeviceCommand command) => command.Domain == "audio";
public override Task SetPowerAsync(bool turnOn, CancellationToken cancellationToken)
=> turnOn ? _music.PlayTestMusicAsync(cancellationToken) : _music.StopMusicAsync(cancellationToken);
public override IReadOnlyList<DeviceParameterDefinition> Parameters { get; }
public override Task ExecuteParameterAsync(DeviceCommand command, CancellationToken cancellationToken)
=> command.Domain == "audio"
? _music.SetManualVolumeAsync((int)command.Data!["volume"]!, cancellationToken)
: base.ExecuteParameterAsync(command, cancellationToken);
public override async Task ExecuteSleepAsync(DeviceSceneContext context, PersonalizedSceneConfig config,
string constitutionCode, CancellationToken cancellationToken)
{
await context.RunAsync(BuildParameterCommand("speaker_sleep_mode", config.EnableSpeakerSleepMode), cancellationToken);
await context.ExternalAsync("播放体质助眠音乐并渐弱", PrimaryEntityId,
token => _music.StartForConstitutionAsync(constitutionCode, token), cancellationToken);
}
public override IEnumerable<DeviceCommand> GetShutdownCommands()
=> [new("关闭音箱睡眠模式", _entities.SpeakerSleepMode, "switch", "turn_off", ExpectedState: "off")];
public override HomeAssistantScenePresetItem? BuildPreview(PersonalizedSceneConfig config, string constitutionCode)
{
if (string.IsNullOrWhiteSpace(PrimaryEntityId)) return null;
var preview = Preview(config.EnableSpeakerSleepMode ? "播放体质音乐并开启睡眠模式" : "播放体质音乐",
"体质曲目循环播放 · 音量50%每5秒降1%",
new() { ["music"] = "四诊体质专属曲目", ["sleep_mode"] = config.EnableSpeakerSleepMode ? "开启" : "关闭", ["volume"] = "50%→0%" });
preview.Available = _music.HasPlayableTracks(constitutionCode);
preview.AvailabilityMessage = preview.Available ? "体质音乐已配置,执行时验证音箱" : "该体质的 OSS 音乐 URL 尚未配置";
return preview;
}
}
@@ -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"
}
};
}
}
@@ -0,0 +1,229 @@
using Evergreen.Health.Application.Health.Devices.Contracts;
using Evergreen.Health.Application.Health.Integrations.HomeAssistant;
using Evergreen.Health.Application.Health.Models;
using Evergreen.Health.Application.Health.Scenes;
using Microsoft.Extensions.Options;
using System.Globalization;
using System.Text.Json;
namespace Evergreen.Health.Application.Health.Devices.Climate;
/// <summary>空调只使用已绑定的 climate 实体;每条命令按设备当前公开能力校验。</summary>
public sealed class ClimateModule : HealthDeviceModule
{
private readonly string _entityId;
private const int TargetTemperatureFeature = 1, FanModeFeature = 8, TurnOffFeature = 128, TurnOnFeature = 256;
private static readonly string[] KnownModes = ["off", "heat", "cool", "fan_only", "auto", "dry", "heat_cool"];
public ClimateModule(DeviceActionRunner runner, IOptions<HomeAssistantOptions> options) : base(runner)
{
_entityId = options.Value.Entities.Climate;
Parameters =
[
new("climate_mode", _entityId, BuildModeCommand, "空调模式"),
new("climate_temperature", _entityId, BuildTemperatureCommand, "目标温度"),
new("climate_fan_mode", _entityId, BuildFanCommand, "风速")
];
}
public override string Key => "climate";
public override string Category => nameof(HealthDeviceCategory.Climate);
public override string DisplayName => "空调";
public override string PrimaryEntityId => _entityId;
public override IReadOnlyList<DeviceParameterDefinition> Parameters { get; }
public override async Task SetPowerAsync(bool turnOn, CancellationToken cancellationToken)
{
var command = PowerCommand(turnOn);
await Runner.SendAsync(command, cancellationToken);
if (!turnOn) await Runner.VerifyParameterAsync(command, cancellationToken);
}
private DeviceCommand PowerCommand(bool turnOn) => new(turnOn ? "开启空调" : "关闭空调", _entityId,
"climate", turnOn ? "turn_on" : "turn_off", ExpectedState: turnOn ? null : "off")
{
ValidateCurrentState = state =>
{
ValidateIdentity(state);
RequireFeature(state, turnOn ? TurnOnFeature : TurnOffFeature, turnOn ? "开启" : "关闭");
},
RetryOnVerificationMismatch = !turnOn
};
private DeviceCommand BuildModeCommand(object? value)
{
var mode = DeviceValues.Choice(value, KnownModes, "空调模式");
return new("设置空调模式", _entityId, "climate", "set_hvac_mode",
new Dictionary<string, object?> { ["hvac_mode"] = mode }, mode)
{
ValidateCurrentState = state =>
{
ValidateIdentity(state);
RequireOption(state, "hvac_modes", mode, "空调模式");
},
RetryOnVerificationMismatch = false
};
}
private DeviceCommand BuildTemperatureCommand(object? value)
{
if (!decimal.TryParse(DeviceValues.Text(value), NumberStyles.Float, CultureInfo.InvariantCulture, out var temperature))
throw new InvalidOperationException("目标温度必须是有效数值");
return new("设置空调目标温度", _entityId, "climate", "set_temperature",
new Dictionary<string, object?> { ["temperature"] = temperature }, temperature.ToString(CultureInfo.InvariantCulture))
{
ExpectedAttribute = "temperature",
ValidateCurrentState = state => ValidateTemperature(state, temperature),
RetryOnVerificationMismatch = false
};
}
private DeviceCommand BuildFanCommand(object? value)
{
var fan = DeviceValues.Text(value);
if (string.IsNullOrWhiteSpace(fan)) throw new InvalidOperationException("风速选项不能为空");
return new("设置空调风速", _entityId, "climate", "set_fan_mode",
new Dictionary<string, object?> { ["fan_mode"] = fan }, fan)
{
ExpectedAttribute = "fan_mode",
ValidateCurrentState = state =>
{
ValidateIdentity(state);
RequireFeature(state, FanModeFeature, "风速设置");
RequireOption(state, "fan_modes", fan, "风速");
},
RetryOnVerificationMismatch = false
};
}
public override async Task ExecuteSleepAsync(DeviceSceneContext context, PersonalizedSceneConfig config,
string constitutionCode, CancellationToken cancellationToken)
{
cancellationToken.ThrowIfCancellationRequested();
var advice = config.ClimateAdvice;
if (string.IsNullOrWhiteSpace(_entityId))
{
Skip(context, "空调实体尚未配置,仅保留室温建议"); return;
}
if (advice.Mode is not ("heat" or "cool"))
{
Skip(context, advice.Mode == "off" ? "室温在建议范围内,本次不额外开启空调,保持现有状态" : advice.Reason); return;
}
if (!context.States.TryGetValue(_entityId, out var state) || !DeviceActionRunner.IsAvailable(state))
{
Skip(context, "空调当前离线或不可用,本次不发送启动指令"); return;
}
// 先校验所有参数,禁止在温度无效时先开启空调。随机值由场景准备阶段只抽一次。
var modeCommand = BuildModeCommand(advice.Mode);
modeCommand.ValidateCurrentState!(state);
var target = ResolveSceneTarget(state, advice);
advice.TargetTemperatureCelsius = target;
var temperatureCommand = BuildTemperatureCommand(target);
temperatureCommand.ValidateCurrentState!(state);
await context.ExternalAsync(modeCommand.Name, _entityId,
token => ExecuteParameterAsync(modeCommand, token), cancellationToken);
if (context.Results.Last().Status != "Succeeded") return;
await context.ExternalAsync(temperatureCommand.Name, _entityId,
token => ExecuteParameterAsync(temperatureCommand, token), cancellationToken);
}
private int ResolveSceneTarget(HomeAssistantState state, ClimateAdviceOutput advice)
{
var (minimum, maximum, step) = TemperatureBounds(state);
if (advice.TargetTemperatureCelsius is not int preparedTarget)
throw new InvalidOperationException("本次空调目标温度尚未生成,不自动使用默认温度");
var low = Math.Max((decimal)advice.RecommendedMinimumCelsius, minimum);
var high = Math.Min((decimal)advice.RecommendedMaximumCelsius, maximum);
if (low > high || high - low > 1000)
throw new InvalidOperationException("设备温度范围与本次室温建议没有有效交集");
var candidates = new List<int>();
for (var candidate = decimal.Ceiling(low); candidate <= decimal.Floor(high); candidate++)
if (IsOnStep(candidate, minimum, step) && candidate >= int.MinValue && candidate <= int.MaxValue)
candidates.Add((int)candidate);
if (candidates.Count == 0)
throw new InvalidOperationException("室温建议范围内没有符合设备步进的整数目标温度");
// 只做确定性范围/步进校正,不因重试或状态刷新重新随机。
return candidates.OrderBy(candidate => Math.Abs((long)candidate - preparedTarget)).ThenBy(candidate => candidate).First();
}
private void ValidateIdentity(HomeAssistantState state)
{
if (!_entityId.StartsWith("climate.", StringComparison.OrdinalIgnoreCase) ||
!string.Equals(state.EntityId, _entityId, StringComparison.OrdinalIgnoreCase))
throw new InvalidOperationException("空调主控实体绑定无效");
}
private void ValidateTemperature(HomeAssistantState state, decimal temperature)
{
ValidateIdentity(state);
var (minimum, maximum, step) = TemperatureBounds(state);
if (temperature < minimum || temperature > maximum)
throw new InvalidOperationException($"该设备目标温度应在 {minimum} 到 {maximum} 之间");
if (!IsOnStep(temperature, minimum, step))
throw new InvalidOperationException($"目标温度必须符合设备 {step}℃ 的调节步进");
}
private static (decimal Minimum, decimal Maximum, decimal Step) TemperatureBounds(HomeAssistantState state)
{
RequireFeature(state, TargetTemperatureFeature, "目标温度设置");
if (state.GetStringAttribute("temperature_unit") != "°C")
throw new InvalidOperationException("尚未确认该空调使用摄氏温度,暂不发送目标温度");
var min = state.GetDoubleAttribute("min_temp");
var max = state.GetDoubleAttribute("max_temp");
var step = state.GetDoubleAttribute("target_temp_step");
if (min is null || max is null || step is null || step <= 0 || min > max ||
min < (double)decimal.MinValue || max > (double)decimal.MaxValue || step > (double)decimal.MaxValue)
throw new InvalidOperationException("空调未提供有效温度范围或调节步进");
var decimalStep = (decimal)step.Value;
if (decimalStep <= 0) throw new InvalidOperationException("空调调节步进无效");
return ((decimal)min.Value, (decimal)max.Value, decimalStep);
}
private static bool IsOnStep(decimal value, decimal minimum, decimal step)
{
var steps = (value - minimum) / step;
return Math.Abs(steps - decimal.Round(steps)) <= 0.00000001m;
}
private static void RequireFeature(HomeAssistantState state, int feature, string label)
{
var features = state.GetDoubleAttribute("supported_features");
if (features is null || features < 0 || features > int.MaxValue || features != Math.Truncate(features.Value) ||
(((int)features.Value & feature) != feature))
throw new InvalidOperationException($"该空调未声明支持{label}");
}
private static void RequireOption(HomeAssistantState state, string attribute, string option, string label)
{
if (state.Attributes.ValueKind != JsonValueKind.Object || !state.Attributes.TryGetProperty(attribute, out var list) ||
list.ValueKind != JsonValueKind.Array || !list.EnumerateArray().Any(item =>
item.ValueKind == JsonValueKind.String && string.Equals(item.GetString(), option, StringComparison.Ordinal)))
throw new InvalidOperationException($"该空调不支持{label}:{option}");
}
private void Skip(DeviceSceneContext context, string message) => context.Results.Add(new()
{ Name = "空调舒睡设置", EntityId = _entityId, Status = "Skipped", Message = message });
public override IEnumerable<DeviceCommand> GetShutdownCommands()
=> string.IsNullOrWhiteSpace(_entityId) ? [] : [PowerCommand(false)];
public override HomeAssistantScenePresetItem? BuildPreview(PersonalizedSceneConfig config, string constitutionCode)
{
var advice = config.ClimateAdvice;
var preview = Preview(advice.Mode switch
{
"heat" => "按室温开启制热", "cool" => "按室温开启制冷", "off" => "不额外开启空调", _ => "等待有效室温"
}, advice.Reason, new()
{
["mode"] = advice.Mode, ["temperature_range"] = $"{advice.RecommendedMinimumCelsius}–{advice.RecommendedMaximumCelsius}℃",
["target_temperature"] = advice.TargetTemperatureCelsius
});
if (string.IsNullOrWhiteSpace(_entityId))
{
preview.Available = false;
preview.AvailabilityMessage = "空调实体尚未配置,仅显示建议";
}
return preview;
}
}
@@ -0,0 +1,28 @@
using Evergreen.Health.Application.Health.Integrations.HomeAssistant;
namespace Evergreen.Health.Application.Health.Devices.Contracts;
public enum DeviceCommandIntent { Normal = 0, TurnRelaxationLightOn = 2, TurnRelaxationLightOff = 3 }
/// <summary>实体由后端模块提供,不接受客户端任意实体或任意服务名。</summary>
public sealed record DeviceCommand(string Name, string EntityId, string Domain, string Service,
IReadOnlyDictionary<string, object?>? Data = null, string? ExpectedState = null)
{
public DeviceCommandIntent Intent { get; init; } = DeviceCommandIntent.Normal;
// 新设备的能力校验由模块提供,所有发送路径(含场景关闭)都不可绕过。
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()
{
var result = Data is null ? new Dictionary<string, object?>() : new Dictionary<string, object?>(Data);
result["entity_id"] = EntityId;
return result;
}
}
public sealed record DeviceParameterDefinition(string Key, string EntityId,
Func<object?, DeviceCommand> Build, string Label,
int? Minimum = null, int? Maximum = null, IReadOnlyList<string>? Options = null);
@@ -0,0 +1,52 @@
using System.Globalization;
using System.Text.Json;
namespace Evergreen.Health.Application.Health.Devices.Contracts;
public static class DeviceValues
{
public static string Text(object? value)
=> value is JsonElement element
? (element.ValueKind == JsonValueKind.String ? element.GetString() ?? "" : element.ToString()).Trim()
: Convert.ToString(value, CultureInfo.InvariantCulture)?.Trim() ?? "";
public static int Integer(object? value, int minimum, int maximum, string label)
{
if (!int.TryParse(Text(value), NumberStyles.Integer, CultureInfo.InvariantCulture, out var number) ||
number < minimum || number > maximum)
throw new InvalidOperationException($"{label}应在 {minimum} 到 {maximum} 之间");
return number;
}
public static bool Boolean(object? value)
{
if (bool.TryParse(Text(value), out var result)) return result;
throw new InvalidOperationException("开关参数无效");
}
public static string Choice(object? value, IReadOnlyList<string> options, string label)
{
var text = Text(value);
if (!options.Contains(text, StringComparer.Ordinal))
throw new InvalidOperationException($"{label}选项无效");
return text;
}
public static DeviceParameterDefinition Switch(string key, string entity, string domain, string label)
=> new(key, entity, value => new(label, entity, domain,
Boolean(value) ? "turn_on" : "turn_off"), label);
public static DeviceParameterDefinition Number(string key, string entity, int min, int max, string label)
=> new(key, entity, value => new(label, entity, "number", "set_value",
new Dictionary<string, object?> { ["value"] = Integer(value, min, max, label) }), label, min, max);
public static DeviceParameterDefinition Select(string key, string entity, IReadOnlyList<string> options, string label,
bool verify = false, DeviceCommandIntent intent = DeviceCommandIntent.Normal)
=> new(key, entity, value =>
{
var option = Choice(value, options, label);
return new(label, entity, "select", "select_option",
new Dictionary<string, object?> { ["option"] = option }, verify ? option : null)
{ Intent = option == "无" ? DeviceCommandIntent.Normal : intent };
}, label, Options: options);
}
@@ -0,0 +1,25 @@
using Evergreen.Health.Application.Health.Integrations.HomeAssistant;
using Evergreen.Health.Application.Health.Models;
using Evergreen.Health.Application.Health.Scenes;
namespace Evergreen.Health.Application.Health.Devices.Contracts;
/// <summary>新增家具实现此契约并注册即可;场景和 API 不枚举具体家具类型。</summary>
public interface IHealthDeviceModule
{
string Key { get; }
string Category { get; }
string DisplayName { get; }
string PrimaryEntityId { get; }
bool PowerUsesIndependentGateway { get; }
IReadOnlyList<DeviceParameterDefinition> Parameters { get; }
IEnumerable<string> EntityIds { get; }
DeviceCommand BuildParameterCommand(string parameterType, object? value);
Task ExecuteParameterAsync(DeviceCommand command, CancellationToken cancellationToken);
bool ParameterUsesIndependentGateway(DeviceCommand command);
Task SetPowerAsync(bool turnOn, CancellationToken cancellationToken);
Task ExecuteSleepAsync(DeviceSceneContext context, PersonalizedSceneConfig config,
string constitutionCode, CancellationToken cancellationToken);
IEnumerable<DeviceCommand> GetShutdownCommands();
HomeAssistantScenePresetItem? BuildPreview(PersonalizedSceneConfig config, string constitutionCode);
}
@@ -0,0 +1,12 @@
using Evergreen.Health.Application.Health.Integrations.HomeAssistant;
using Evergreen.Health.Application.Health.Models;
namespace Evergreen.Health.Application.Health.Devices.Contracts;
/// <summary>按设备实例实际能力处理标准手动参数,不绑定单一型号,也不登记自动场景。</summary>
public interface IStandardDeviceControl
{
IReadOnlyList<string> ParameterKeys { get; }
DeviceCommand Build(HomeAssistantDiscoveredDeviceOutput device, HomeAssistantState state,
string parameterType, object? value);
}
@@ -0,0 +1,48 @@
using Evergreen.Health.Application.Health.Integrations.HomeAssistant;
using Evergreen.Health.Application.Health.Scenes;
using Evergreen.Health.Application.Health.Sleep;
using Microsoft.Extensions.Options;
namespace Evergreen.Health.Application.Health.Devices.Curtain;
public sealed class BedPresenceCurtainCloseAction(CurtainModule curtain, DeviceActionRunner runner,
IOptions<HomeAssistantOptions> options) : IBedPresenceAction
{
public string Key => "curtain-close";
public string Description => "关闭窗帘";
public TimeSpan ExecutionTimeout => TimeSpan.FromSeconds(55);
public bool SupportsTarget(string entityId) => options.Value.Enabled &&
entityId?.StartsWith("cover.", StringComparison.Ordinal) == true && entityId == curtain.PrimaryEntityId;
public async Task<string> ExecuteAsync(string entityId, CancellationToken token)
{
if (!SupportsTarget(entityId)) return "target-not-configured";
var state = await runner.ReadAvailableStateAsync(entityId, token);
if (state.State == "closed") return "already-closed";
if (state.State == "closing") return "already-closing";
if (state.State != "open") return "state-not-open";
var close = curtain.BuildParameterCommand("curtain_action", "close");
var command = close with
{
ExpectedState = "closed",
ValidateCurrentState = current =>
{
close.ValidateCurrentState?.Invoke(current);
if (current.State != "open") throw new InvalidOperationException("窗帘状态已改变,跳过关帘");
}
};
await runner.SendAsync(command, token);
try
{
// 机械行程需要较长回读时间。沿用窗帘命令的只读回读配置,失败不重发关帘。
await runner.VerifyParameterAsync(command, token, verificationAttempts: 20);
return "readback-confirmed";
}
catch (InvalidOperationException)
{
return "sent-readback-unconfirmed";
}
}
}
@@ -0,0 +1,76 @@
using Evergreen.Health.Application.Health.Devices.Contracts;
using Evergreen.Health.Application.Health.Integrations.HomeAssistant;
using Evergreen.Health.Application.Health.Models;
using Evergreen.Health.Application.Health.Scenes;
using Microsoft.Extensions.Options;
namespace Evergreen.Health.Application.Health.Devices.Curtain;
/// <summary>集中定义窗帘开合动作和能力校验,供手动控制与独立联动复用。</summary>
public sealed class CurtainModule : HealthDeviceModule
{
private readonly string _entityId;
public CurtainModule(DeviceActionRunner runner, IOptions<HomeAssistantOptions> options) : base(runner)
{
_entityId = options.Value.Entities.Curtain;
Parameters =
[
new("curtain_action", _entityId, BuildAction, "窗帘动作", Options: ["open", "stop", "close"]),
new("curtain_position", _entityId, BuildPosition, "窗帘开度", 0, 100)
];
}
public override string Key => "curtain";
public override string Category => nameof(HealthDeviceCategory.Curtain);
public override string DisplayName => "窗帘";
public override string PrimaryEntityId => _entityId;
public override IReadOnlyList<DeviceParameterDefinition> Parameters { get; }
private DeviceCommand BuildAction(object? value)
{
var action = DeviceValues.Choice(value, ["open", "stop", "close"], "窗帘动作");
var (service, feature, label) = action switch
{
"open" => ("open_cover", 1, "打开窗帘"), "close" => ("close_cover", 2, "关闭窗帘"),
_ => ("stop_cover", 8, "停止窗帘")
};
return new(label, _entityId, "cover", service)
{
ValidateCurrentState = state => ValidateFeature(state, feature, label),
RetryOnVerificationMismatch = false
};
}
private DeviceCommand BuildPosition(object? value)
{
var position = DeviceValues.Integer(value, 0, 100, "窗帘开度");
return new("设置窗帘开度", _entityId, "cover", "set_cover_position",
new Dictionary<string, object?> { ["position"] = position })
{
ValidateCurrentState = state => ValidateFeature(state, 4, "设置窗帘开度"),
RetryOnVerificationMismatch = false
};
}
private void ValidateFeature(HomeAssistantState state, int feature, string label)
{
if (!_entityId.StartsWith("cover.", StringComparison.OrdinalIgnoreCase) ||
!string.Equals(state.EntityId, _entityId, StringComparison.OrdinalIgnoreCase))
throw new InvalidOperationException("窗帘主控实体绑定无效");
var features = state.GetDoubleAttribute("supported_features");
if (features is null || features < 0 || features > int.MaxValue || features != Math.Truncate(features.Value) ||
(((int)features.Value & feature) != feature))
throw new InvalidOperationException($"该窗帘未声明支持{label}");
}
public override Task SetPowerAsync(bool turnOn, CancellationToken cancellationToken)
=> throw new InvalidOperationException("窗帘没有电源开关,请使用打开、停止或关闭控制");
public override Task ExecuteSleepAsync(DeviceSceneContext context, PersonalizedSceneConfig config,
string constitutionCode, CancellationToken cancellationToken)
{
cancellationToken.ThrowIfCancellationRequested();
context.Results.Add(new() { Name = "窗帘场景联动", EntityId = _entityId, Status = "Skipped",
Message = string.IsNullOrWhiteSpace(_entityId) ? "窗帘实体尚未配置" : "一键舒睡保持窗帘当前位置" });
return Task.CompletedTask;
}
}
@@ -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";
}
}
@@ -0,0 +1,54 @@
using Evergreen.Health.Application.Health.Devices.Contracts;
using Evergreen.Health.Application.Health.Integrations.HomeAssistant;
using Evergreen.Health.Application.Health.Models;
using Evergreen.Health.Application.Health.Scenes;
using Microsoft.Extensions.Options;
namespace Evergreen.Health.Application.Health.Devices.Dehumidifier;
public sealed class DehumidifierModule : HealthDeviceModule
{
private readonly HomeAssistantEntityOptions _entities;
public DehumidifierModule(DeviceActionRunner runner, IOptions<HomeAssistantOptions> options) : base(runner)
{
_entities = options.Value.Entities;
Parameters =
[
new("humidity", PrimaryEntityId, value => new("设置目标湿度", PrimaryEntityId, "humidifier", "set_humidity",
new Dictionary<string, object?> { ["humidity"] = DeviceValues.Integer(value, 40, 70, "目标湿度") }), "目标湿度", 40, 70),
new("fan_level", _entities.DehumidifierFanLevel, value => new("设置风速", _entities.DehumidifierFanLevel, "select", "select_option",
new Dictionary<string, object?> { ["option"] = DeviceValues.Choice(DeviceValues.Text(value).Replace('档', '挡'),
["1挡", "2挡", "3挡"], "风速档位") }), "风速档位", Options: ["1挡", "2挡", "3挡"]),
new("dehumidifier_mode", PrimaryEntityId, value => new("设置除湿模式", PrimaryEntityId, "humidifier", "set_mode",
new Dictionary<string, object?> { ["mode"] = DeviceValues.Choice(value, ["睡眠", "智能"], "除湿模式") }),
"除湿模式", Options: ["睡眠", "智能"]),
DeviceValues.Switch("uv", _entities.DehumidifierUv, "switch", "UV 杀菌")
];
}
public override string Key => "dehumidifier";
public override string Category => nameof(HealthDeviceCategory.EnvironmentControl);
public override string DisplayName => "除湿机";
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(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);
await context.RunAsync(BuildParameterCommand("uv", config.EnableUv), cancellationToken);
}
public override IEnumerable<DeviceCommand> GetShutdownCommands()
=> [new("关闭除湿机", PrimaryEntityId, "humidifier", "turn_off", ExpectedState: "off")];
public override HomeAssistantScenePresetItem? BuildPreview(PersonalizedSceneConfig config, string constitutionCode)
=> string.IsNullOrWhiteSpace(PrimaryEntityId) ? null : Preview("开启恒湿舒睡",
$"目标湿度 {config.TargetHumidity}% · {config.DehumidifierMode}模式 · 风速{config.FanLevel} · UV{(config.EnableUv ? "开" : "关")}",
new() { ["target_humidity"] = $"{config.TargetHumidity}%", ["mode"] = config.DehumidifierMode,
["fan_level"] = config.FanLevel, ["uv"] = config.EnableUv ? "开启" : "关闭" });
}
@@ -0,0 +1,35 @@
using Evergreen.Health.Application.Health.Devices.Contracts;
namespace Evergreen.Health.Application.Health.Devices;
public sealed class DeviceModuleRegistry
{
public IReadOnlyList<IHealthDeviceModule> Modules { get; }
private readonly IReadOnlyDictionary<string, IHealthDeviceModule> _parameterOwners;
public DeviceModuleRegistry(IEnumerable<IHealthDeviceModule> modules)
{
Modules = modules.ToArray();
if (Modules.Select(module => module.Key).Distinct(StringComparer.OrdinalIgnoreCase).Count() != Modules.Count)
throw new InvalidOperationException("设备模块 Key 重复");
var owners = new Dictionary<string, IHealthDeviceModule>(StringComparer.OrdinalIgnoreCase);
foreach (var module in Modules)
foreach (var parameter in module.Parameters)
if (!owners.TryAdd(parameter.Key, module))
throw new InvalidOperationException($"设备参数重复注册:{parameter.Key}");
_parameterOwners = owners;
}
public IHealthDeviceModule ForParameter(string parameterType)
=> _parameterOwners.TryGetValue(parameterType, out var module)
? module : throw new InvalidOperationException($"不支持的参数类型:{parameterType}");
public IHealthDeviceModule? ForEntities(IEnumerable<string> entityIds)
{
var ids = entityIds.ToHashSet(StringComparer.OrdinalIgnoreCase);
return Modules.Select(module => new { Module = module,
Score = module.EntityIds.Count(id => !string.IsNullOrWhiteSpace(id) && ids.Contains(id)) })
.Where(item => item.Score > 0).OrderByDescending(item => item.Score)
.Select(item => item.Module).FirstOrDefault();
}
}
@@ -0,0 +1,66 @@
using Evergreen.Health.Application.Health.Devices.Contracts;
using Evergreen.Health.Application.Health.Integrations.HomeAssistant;
using Evergreen.Health.Application.Health.Models;
using Evergreen.Health.Application.Health.Scenes;
namespace Evergreen.Health.Application.Health.Devices;
/// <summary>共用归属校验和路由;标准能力处理普通参数,专属模块保留自己的执行与场景逻辑。</summary>
public sealed class DeviceParameterController
{
private readonly DeviceModuleRegistry _modules;
private readonly DeviceActionRunner _runner;
private readonly Dictionary<string, IStandardDeviceControl> _standard = new(StringComparer.OrdinalIgnoreCase);
public DeviceParameterController(DeviceModuleRegistry modules, DeviceActionRunner runner,
IEnumerable<IStandardDeviceControl> standard)
{
(_modules, _runner) = (modules, runner);
foreach (var adapter in standard)
foreach (var key in adapter.ParameterKeys)
if (!_standard.TryAdd(key, adapter)) throw new InvalidOperationException($"标准设备参数重复:{key}");
}
public async Task<DeviceCommand> ExecuteAsync(HomeAssistantDiscoveredDeviceOutput device,
IReadOnlyDictionary<string, HomeAssistantState> states, string parameterType, object? value,
string? requestedEntityId, CancellationToken cancellationToken)
{
cancellationToken.ThrowIfCancellationRequested();
IHealthDeviceModule? module;
DeviceCommand command;
if (_standard.TryGetValue(parameterType, out var adapter))
{
var primary = device.PrimaryControlEntityId;
if (string.IsNullOrWhiteSpace(primary) || !device.Entities.Any(entity =>
string.Equals(entity.EntityId, primary, StringComparison.OrdinalIgnoreCase)) ||
!states.TryGetValue(primary, out var state))
throw new InvalidOperationException("设备主控实体未确认");
command = adapter.Build(device, state, parameterType, value);
module = _modules.ForEntities(device.Entities.Select(entity => entity.EntityId));
// 附属灯不能借用另一主控的执行钩子;仅复用该实例所属的专属模块。
if (module is not null && !string.Equals(module.PrimaryEntityId, command.EntityId, StringComparison.OrdinalIgnoreCase))
module = null;
}
else
{
module = _modules.ForParameter(parameterType);
command = module.BuildParameterCommand(parameterType, value);
}
if (string.IsNullOrWhiteSpace(command.EntityId))
throw new InvalidOperationException("该参数尚未配置 Home Assistant 实体");
if (!string.IsNullOrWhiteSpace(requestedEntityId) &&
!string.Equals(requestedEntityId, command.EntityId, StringComparison.OrdinalIgnoreCase))
throw new InvalidOperationException("请求实体与该参数的受信任实体不匹配");
if (!device.Entities.Any(entity => string.Equals(entity.EntityId, command.EntityId, StringComparison.OrdinalIgnoreCase)))
throw new InvalidOperationException("该参数不属于指定设备");
if (module?.ParameterUsesIndependentGateway(command) != true &&
(!states.TryGetValue(command.EntityId, out var current) || !DeviceActionRunner.IsAvailable(current)))
throw new InvalidOperationException("目标参数实体当前离线或不可用");
if (module is not null) await module.ExecuteParameterAsync(command, cancellationToken);
else
{
await _runner.SendAsync(command, cancellationToken);
if (command.ExpectedState is not null) await _runner.VerifyParameterAsync(command, cancellationToken);
}
return command;
}
}
@@ -0,0 +1,388 @@
using Evergreen.Health.Application.Health.Devices;
using Evergreen.Health.Application.Health.Integrations.HomeAssistant;
using Evergreen.Health.Application.Health.Models;
using Evergreen.Health.Application.Health.Scenes;
using Microsoft.Extensions.Options;
using System.Globalization;
using System.Text.Json;
namespace Evergreen.Health.Application.Health.Devices.Discovery;
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);
_manualLightIds = _options.ManualLightEntityIds
.Where(id => !string.IsNullOrWhiteSpace(id) && id.StartsWith("light.", StringComparison.Ordinal))
.ToHashSet(StringComparer.OrdinalIgnoreCase);
}
public List<HomeAssistantDiscoveredDeviceOutput> BuildDiscoveredDevices(
HomeAssistantRegistries? registries,
IReadOnlyDictionary<string, HomeAssistantState> stateMap)
{
var discovered = new List<HomeAssistantDiscoveredDeviceOutput>();
if (registries is not null && registries.Devices.Count > 0)
{
var areaMap = registries.Areas.ToDictionary(a => a.AreaId, a => a.Name, StringComparer.OrdinalIgnoreCase);
var entitiesByDevice = registries.Entities
.Where(e => !string.IsNullOrWhiteSpace(e.DeviceId) && string.IsNullOrWhiteSpace(e.DisabledBy))
.GroupBy(e => e.DeviceId!, StringComparer.OrdinalIgnoreCase)
.ToDictionary(g => g.Key, g => g.ToList(), StringComparer.OrdinalIgnoreCase);
// 使用完整注册表识别退役设备;即使用于识别的实体被禁用,也不能重新发现同一设备。
var excludedDevices = registries.Entities.Where(entity =>
_options.ExcludedDeviceEntityIds.Contains(entity.EntityId, StringComparer.OrdinalIgnoreCase))
.Select(entity => entity.DeviceId).ToHashSet(StringComparer.OrdinalIgnoreCase);
// HA 将 Sun、Backup 等虚拟系统服务标记为 service,不属于家具或实体传感器。
foreach (var device in registries.Devices.Where(device => string.IsNullOrWhiteSpace(device.DisabledBy) &&
!string.Equals(device.EntryType, "service", StringComparison.OrdinalIgnoreCase) && !excludedDevices.Contains(device.Id)))
{
var regEntities = entitiesByDevice.GetValueOrDefault(device.Id) ?? [];
if (regEntities.Count == 0) continue;
var entityIds = regEntities.Select(entity => entity.EntityId).ToHashSet(StringComparer.OrdinalIgnoreCase);
var module = _modules.ForEntities(entityIds);
var displayOnly = _options.DisplayOnlyDevices.FirstOrDefault(item => entityIds.Contains(item.EntityId));
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)
{
stateMap.TryGetValue(regEntity.EntityId, out var state);
var available = state is not null && IsAvailable(state);
var domain = regEntity.EntityId.Contains('.') ? regEntity.EntityId.Split('.')[0] : "";
var name = !string.IsNullOrWhiteSpace(regEntity.Name)
? regEntity.Name
: (!string.IsNullOrWhiteSpace(regEntity.OriginalName) ? regEntity.OriginalName : regEntity.EntityId);
entityOutputs.Add(new HomeAssistantEntityOutput
{
EntityId = regEntity.EntityId,
Domain = domain,
Name = name,
State = state?.State ?? "unavailable",
Available = available,
Unit = state?.GetStringAttribute("unit_of_measurement"),
Attributes = ExtractSafeAttributes(state)
});
}
var isOnline = regEntities.Any(e =>
{
if (!string.IsNullOrWhiteSpace(e.DisabledBy)) return false;
return stateMap.TryGetValue(e.EntityId, out var s) && IsAvailable(s);
});
var displayName = !string.IsNullOrWhiteSpace(device.NameByUser)
? device.NameByUser
: (!string.IsNullOrWhiteSpace(device.Name) ? device.Name : "智能设备");
var areaName = !string.IsNullOrWhiteSpace(device.AreaId) && areaMap.TryGetValue(device.AreaId, out var an)
? an
: null;
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 not null ? FindPrimaryControlEntityId(category, entityOutputs) :
manualLightId ?? (displayOnly is not null ? null : FindPrimaryControlEntityId(category, entityOutputs));
discovered.Add(new HomeAssistantDiscoveredDeviceOutput
{
DeviceId = device.Id,
ModuleKey = module?.Key ?? "",
Name = displayName,
Manufacturer = device.Manufacturer ?? "",
Model = device.Model ?? "",
AreaName = areaName,
Category = category,
Online = isOnline,
PrimaryControlEntityId = primaryControlEntityId,
DefaultLightEffect = DefaultLightEffect(primaryControlEntityId),
Entities = entityOutputs,
Metrics = metrics
});
}
}
// 有效注册表被过滤后为空,不代表注册表读取失败,不能重新生成占位设备。
if (registries is null || registries.Devices.Count == 0)
{
discovered = BuildFallbackDevices(stateMap);
}
return discovered;
}
private List<HomeAssistantDiscoveredDeviceOutput> BuildFallbackDevices(IReadOnlyDictionary<string, HomeAssistantState> stateMap)
{
var list = new List<HomeAssistantDiscoveredDeviceOutput>();
// 注册信息暂不可读时只展示真实配置过的模块,不凭空创建床垫/体脂秤。
foreach (var module in _modules.Modules)
{
var entityOutputs = module.EntityIds.Select(id =>
{
stateMap.TryGetValue(id, out var state);
return new HomeAssistantEntityOutput
{
EntityId = id, Domain = id.Split('.')[0], Name = state?.GetStringAttribute("friendly_name") ?? module.DisplayName,
State = state?.State ?? "unavailable", Available = state is not null && IsAvailable(state),
Unit = state?.GetStringAttribute("unit_of_measurement"), Attributes = ExtractSafeAttributes(state)
};
}).ToList();
if (entityOutputs.Count == 0) continue;
list.Add(new()
{
DeviceId = $"fallback_{module.Key}", ModuleKey = module.Key, Name = module.DisplayName,
Category = module.Category, Online = entityOutputs.Any(entity => entity.Available),
PrimaryControlEntityId = FindPrimaryControlEntityId(module.Category, entityOutputs),
DefaultLightEffect = DefaultLightEffect(module.PrimaryEntityId),
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);
list.Add(new()
{
DeviceId = $"fallback_{device.EntityId}", Name = device.Name, Category = device.Category,
Online = available, Entities = [new() { EntityId = device.EntityId, Domain = device.EntityId.Split('.')[0],
Name = state?.GetStringAttribute("friendly_name") ?? device.Name, State = state?.State ?? "unavailable",
Available = available, Attributes = ExtractSafeAttributes(state) }]
});
}
return list;
}
private string? DefaultLightEffect(string? entityId) => _options.AdditionalSleepLights.FirstOrDefault(light =>
string.Equals(light.EntityId, entityId, StringComparison.OrdinalIgnoreCase))?.SleepEffect;
private string? FindPrimaryControlEntityId(
string category,
IReadOnlyList<HomeAssistantEntityOutput> entities)
{
var module = _modules.ForEntities(entities.Select(entity => entity.EntityId));
if (module is not null)
return !string.IsNullOrWhiteSpace(module.PrimaryEntityId) &&
entities.Any(entity => string.Equals(entity.EntityId, module.PrimaryEntityId, StringComparison.OrdinalIgnoreCase))
? module.PrimaryEntityId : null;
return category switch
{
nameof(HealthDeviceCategory.EnvironmentControl) => entities.FirstOrDefault(e => e.Domain == "humidifier")?.EntityId,
nameof(HealthDeviceCategory.Lighting) => entities.FirstOrDefault(e => e.Domain == "light")?.EntityId,
nameof(HealthDeviceCategory.Media) => entities.FirstOrDefault(e => e.Domain == "media_player")?.EntityId,
nameof(HealthDeviceCategory.Climate) => entities.FirstOrDefault(e => e.Domain == "climate")?.EntityId,
_ => null
};
}
public static HomeAssistantSummaryOutput BuildSummary(
IReadOnlyList<HomeAssistantDiscoveredDeviceOutput> devices)
{
var totalDevices = devices.Count;
var onlineDevices = devices.Count(d => d.Online);
var offlineDevices = totalDevices - onlineDevices;
var totalEntities = devices.Sum(d => d.Entities.Count);
var availableEntities = devices.Sum(d => d.Entities.Count(e => e.Available));
return new HomeAssistantSummaryOutput
{
TotalDevices = totalDevices,
OnlineDevices = onlineDevices,
OfflineDevices = offlineDevices,
TotalEntities = totalEntities,
AvailableEntities = availableEntities
};
}
public HomeAssistantSceneDevicesSummaryOutput BuildSceneDevicesSummary(IReadOnlyDictionary<string, HomeAssistantState> stateMap)
{
var configured = _modules.Modules.Where(module => module.EntityIds.Any()).ToArray();
var sceneAvailableCount = configured.Count(module => module.EntityIds.Any(entityId =>
stateMap.TryGetValue(entityId, out var state) && IsAvailable(state)));
return new HomeAssistantSceneDevicesSummaryOutput
{
Total = configured.Length,
Available = sceneAvailableCount
};
}
public HomeAssistantEnvironmentOutput BuildEnvironmentOutput(
IReadOnlyDictionary<string, HomeAssistantState> stateMap,
IReadOnlyList<HomeAssistantDiscoveredDeviceOutput> devices)
{
var entities = _options.Entities;
var envDevice = devices.FirstOrDefault(d => d.Category == nameof(HealthDeviceCategory.EnvironmentSensor));
double? GetVal(string entityId, string metricKey)
{
if (!string.IsNullOrWhiteSpace(entityId))
{
return stateMap.TryGetValue(entityId, out var state) && IsAvailable(state) &&
double.TryParse(state.State, NumberStyles.Float, CultureInfo.InvariantCulture, out var value) &&
double.IsFinite(value) ? value : null;
}
if (envDevice?.Metrics.TryGetValue(metricKey, out var metric) == true && metric is not null &&
double.TryParse(Convert.ToString(metric, CultureInfo.InvariantCulture), NumberStyles.Float,
CultureInfo.InvariantCulture, out var fallback) && double.IsFinite(fallback)) return fallback;
return null;
}
var humidity = GetVal(entities.Humidity, "humidity");
if (humidity == null && !string.IsNullOrWhiteSpace(entities.Dehumidifier) &&
stateMap.TryGetValue(entities.Dehumidifier, out var dehumState) && IsAvailable(dehumState))
{
humidity = dehumState.GetDoubleAttribute("current_humidity");
}
return new HomeAssistantEnvironmentOutput
{
Temperature = GetVal(entities.Temperature, "temperature"),
Humidity = humidity,
Pm25 = GetVal(entities.Pm25, "pm25"),
Pm10 = GetVal(string.Empty, "pm10"),
Co2 = GetVal(entities.Co2, "co2"),
Hcho = GetVal(entities.Formaldehyde, "hcho"),
Tvoc = GetVal(entities.Tvoc, "tvoc")
};
}
private static string CategorizeDevice(
string name,
string model,
string manufacturer,
IReadOnlyList<HomeAssistantEntityOutput> entities)
{
var text = $"{name} {model} {manufacturer}".ToLowerInvariant();
if (text.Contains("je01") || text.Contains("睡眠监测") || text.Contains("睡眠检测") || text.Contains("雷达"))
return nameof(HealthDeviceCategory.SleepMonitoring);
if (entities.Any(entity => entity.Domain == "climate")) return nameof(HealthDeviceCategory.Climate);
if (entities.Any(entity => entity.Domain == "cover")) return nameof(HealthDeviceCategory.Curtain);
if (text.Contains("除湿") || text.Contains("derh") || entities.Any(e => e.Domain == "humidifier"))
return nameof(HealthDeviceCategory.EnvironmentControl);
if (text.Contains("空气检测") || text.Contains("airm") || text.Contains("et71"))
return nameof(HealthDeviceCategory.EnvironmentSensor);
if (text.Contains("床垫") || text.Contains("mattress"))
return nameof(HealthDeviceCategory.Sleep);
if (text.Contains("体脂") || text.Contains("scale"))
return nameof(HealthDeviceCategory.BodyComposition);
if (text.Contains("香氛") || text.Contains("diffuser") || text.Contains("xw002"))
return nameof(HealthDeviceCategory.Fragrance);
if (text.Contains("灯") || text.Contains("light") || text.Contains("lemesh"))
return nameof(HealthDeviceCategory.Lighting);
if (text.Contains("sound") || text.Contains("音箱") || text.Contains("wifispeaker") || text.Contains("l17a") || entities.Any(e => e.Domain == "media_player"))
return nameof(HealthDeviceCategory.Media);
return nameof(HealthDeviceCategory.Other);
}
private static Dictionary<string, object?> ExtractDeviceMetrics(string category, IReadOnlyList<HomeAssistantEntityOutput> entities)
{
var metrics = new Dictionary<string, object?>(StringComparer.OrdinalIgnoreCase);
foreach (var entity in entities)
{
if (!entity.Available) continue;
var id = entity.EntityId.ToLowerInvariant();
if (id.Contains("relative_humidity") || (entity.Domain == "humidifier" && entity.Attributes?.ContainsKey("current_humidity") == true))
{
metrics["humidity"] = entity.Attributes?.GetValueOrDefault("current_humidity") ?? entity.State;
}
if (entity.Domain == "humidifier")
{
if (entity.Attributes?.TryGetValue("humidity", out var th) == true) metrics["targetHumidity"] = th;
if (entity.Attributes?.TryGetValue("mode", out var mode) == true) metrics["mode"] = mode;
}
if (id.Contains("temperature")) metrics["temperature"] = entity.State;
if (id.Contains("pm2_5") || id.Contains("pm25")) metrics["pm25"] = entity.State;
if (id.Contains("pm10")) metrics["pm10"] = entity.State;
if (id.Contains("co2")) metrics["co2"] = entity.State;
if (id.Contains("hcho") || id.Contains("formaldehyde")) metrics["hcho"] = entity.State;
if (id.Contains("tvoc")) metrics["tvoc"] = entity.State;
if (id.Contains("weight")) metrics["weight"] = entity.State;
if (id.Contains("body_fat") || id.Contains("fat_rate")) metrics["bodyFatRate"] = entity.State;
if (id.Contains("sleep_state") || id.Contains("sleep_status")) metrics["sleepStatus"] = entity.State;
}
return metrics;
}
public static Dictionary<string, object?>? ExtractSafeAttributes(HomeAssistantState? state)
{
if (state == null || state.Attributes.ValueKind != JsonValueKind.Object) return null;
var safe = new Dictionary<string, object?>(StringComparer.OrdinalIgnoreCase);
var safeKeys = new[] {
"friendly_name", "unit_of_measurement", "device_class",
"current_humidity", "humidity", "mode", "fan_mode", "percentage",
"brightness", "color_temp", "color_temp_kelvin",
"supported_color_modes", "color_mode", "min_color_temp_kelvin", "max_color_temp_kelvin", "effect_list", "effect",
"volume_level", "is_volume_muted", "media_title",
"battery_level", "temperature", "options", "min", "max", "step", "hvac_modes", "supported_features",
"current_temperature", "temperature_unit", "min_temp", "max_temp", "target_temp_step",
"fan_modes", "swing_modes", "swing_mode", "hvac_action", "current_position"
};
foreach (var key in safeKeys)
{
if (state.Attributes.TryGetProperty(key, out var prop))
{
safe[key] = ToApiAttributeValue(prop);
}
}
return safe.Count > 0 ? safe : null;
}
// HA 用 System.Text.Json 读取,API 用 Newtonsoft.Json 输出。不能把 JsonElement
// 留在 object[] 中,否则选项被输出为 ValueKind 对象,而不是原始字符串。
private static object? ToApiAttributeValue(JsonElement value) => value.ValueKind switch
{
JsonValueKind.String => value.GetString(),
JsonValueKind.Number => value.TryGetInt64(out var integer) ? (object)integer : value.GetDouble(),
JsonValueKind.True => true,
JsonValueKind.False => false,
JsonValueKind.Array => value.EnumerateArray().Select(ToApiAttributeValue).ToArray(),
JsonValueKind.Object => value.EnumerateObject().ToDictionary(property => property.Name,
property => ToApiAttributeValue(property.Value), StringComparer.Ordinal),
_ => null
};
private static bool IsAvailable(HomeAssistantState state) => DeviceActionRunner.IsAvailable(state);
}
@@ -0,0 +1,23 @@
using Evergreen.Health.Application.Health.Devices.Contracts;
using Evergreen.Health.Application.Health.Integrations.HomeAssistant;
using Evergreen.Health.Application.Health.Models;
using Evergreen.Health.Application.Health.Scenes;
using Microsoft.Extensions.Options;
namespace Evergreen.Health.Application.Health.Devices.Environment;
public sealed class EnvironmentModule : HealthDeviceModule
{
private readonly HomeAssistantEntityOptions _entities;
public EnvironmentModule(DeviceActionRunner runner, IOptions<HomeAssistantOptions> options) : base(runner)
=> _entities = options.Value.Entities;
public override string Key => "environment";
public override string Category => nameof(HealthDeviceCategory.EnvironmentSensor);
public override string DisplayName => "室内环境检测仪";
public override string PrimaryEntityId => "";
public override IReadOnlyList<DeviceParameterDefinition> Parameters => [];
public override IEnumerable<string> EntityIds => new[]
{
_entities.Temperature, _entities.Humidity, _entities.Pm25, _entities.Co2, _entities.Formaldehyde, _entities.Tvoc
}.Where(id => !string.IsNullOrWhiteSpace(id)).Distinct(StringComparer.OrdinalIgnoreCase);
}
@@ -0,0 +1,42 @@
using Evergreen.Health.Application.Health.Integrations.HomeAssistant;
using Evergreen.Health.Application.Health.Policies;
using Evergreen.Health.Application.Health.Scenes;
using Microsoft.Extensions.Options;
using System.Globalization;
namespace Evergreen.Health.Application.Health.Devices.Environment;
public sealed class IndoorEnvironmentReader
{
private readonly HomeAssistantEntityOptions _entities;
private readonly TimeProvider _time;
public IndoorEnvironmentReader(IOptions<HomeAssistantOptions> options, TimeProvider time)
=> (_entities, _time) = (options.Value.Entities, time);
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 (state is null || !DeviceActionRunner.IsAvailable(state))
return new(null, null, false, "室内温度实体未配置或不可用") { Issue = IndoorTemperatureIssue.Unavailable };
var observedAt = state.LastReported ?? state.LastUpdated;
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, "室内温度数据无效") { 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, "室内温度单位未识别") { 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";
}
}
@@ -0,0 +1,84 @@
using Evergreen.Health.Application.Health.Devices.Contracts;
using Evergreen.Health.Application.Health.Integrations.HomeAssistant;
using Evergreen.Health.Application.Health.Models;
using Evergreen.Health.Application.Health.Scenes;
using Microsoft.Extensions.Options;
namespace Evergreen.Health.Application.Health.Devices.Fragrance;
public sealed class FragranceModule : HealthDeviceModule
{
private readonly HomeAssistantEntityOptions _entities;
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;
_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", 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("设置出香间隔", intervalEntity, "number", "set_value",
new Dictionary<string, object?> { ["value"] = interval });
}, "出香间隔", 10, 20),
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 => _wakeDevice ? "wake-fragrance" : "fragrance";
public override string Category => nameof(HealthDeviceCategory.Fragrance);
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)
{
var level = Math.Clamp(config.FragranceLevel, 1, 3);
await context.RunAsync(BuildParameterCommand("fragrance_duration", level * 2), cancellationToken);
await context.RunAsync(BuildParameterCommand("fragrance_interval", 25 - level * 5), cancellationToken);
}
await context.RunAsync(new(config.EnableFragrance ? "开启香氛灯" : "关闭香氛灯",
_entities.FragranceLight, "light", config.EnableFragrance ? "turn_on" : "turn_off",
config.EnableFragrance ? new Dictionary<string, object?> { ["brightness_pct"] = config.LightBrightnessPercent } : null),
cancellationToken);
}
public override IEnumerable<DeviceCommand> GetShutdownCommands() => _wakeDevice ? [] :
[
new("关闭香氛机", PrimaryEntityId, "switch", "turn_off", ExpectedState: "off"),
new("关闭香氛灯", _entities.FragranceLight, "light", "turn_off", ExpectedState: "off"),
new("关闭感应夜灯", _entities.FragranceAutoLight, "switch", "turn_off", ExpectedState: "off"),
new("关闭人来增香", _entities.FragranceAutoRelease, "switch", "turn_off", ExpectedState: "off")
];
public override HomeAssistantScenePresetItem? BuildPreview(PersonalizedSceneConfig config, string constitutionCode)
=> _wakeDevice || string.IsNullOrWhiteSpace(PrimaryEntityId) ? null : Preview(config.EnableFragrance ? "开启香氛释放" : "关闭香氛释放",
config.EnableFragrance ? "开启香氛机;香氛灯可用时同步开启柔光" : "当前体质预设不启动香氛",
new() { ["power"] = config.EnableFragrance ? "开启" : "关闭" });
}
@@ -0,0 +1,52 @@
using Evergreen.Health.Application.Health.Devices.Contracts;
using Evergreen.Health.Application.Health.Models;
using Evergreen.Health.Application.Health.Scenes;
namespace Evergreen.Health.Application.Health.Devices;
public abstract class HealthDeviceModule : IHealthDeviceModule
{
protected DeviceActionRunner Runner { get; }
protected HealthDeviceModule(DeviceActionRunner runner) => Runner = runner;
public abstract string Key { get; }
public abstract string Category { get; }
public abstract string DisplayName { get; }
public abstract string PrimaryEntityId { get; }
public virtual bool PowerUsesIndependentGateway => false;
public virtual bool ParameterUsesIndependentGateway(DeviceCommand command) => false;
public abstract IReadOnlyList<DeviceParameterDefinition> Parameters { get; }
public virtual IEnumerable<string> EntityIds => Parameters.Select(parameter => parameter.EntityId)
.Append(PrimaryEntityId).Where(id => !string.IsNullOrWhiteSpace(id)).Distinct(StringComparer.OrdinalIgnoreCase);
public DeviceCommand BuildParameterCommand(string parameterType, object? value)
=> (Parameters.FirstOrDefault(parameter => string.Equals(parameter.Key, parameterType, StringComparison.OrdinalIgnoreCase))
?? throw new InvalidOperationException($"不支持的参数类型:{parameterType}")).Build(value);
public virtual async Task ExecuteParameterAsync(DeviceCommand command, CancellationToken cancellationToken)
{
await Runner.SendAsync(command, cancellationToken);
if (command.ExpectedState is not null)
await Runner.VerifyParameterAsync(command, cancellationToken);
}
public virtual Task SetPowerAsync(bool turnOn, CancellationToken cancellationToken)
{
var domain = PrimaryEntityId.Split('.')[0];
if (domain is not ("switch" or "humidifier" or "light" or "media_player" or "climate"))
throw new InvalidOperationException("该模块没有安全的主控开关");
return Runner.SendAsync(new(turnOn ? "开启设备" : "关闭设备", PrimaryEntityId, domain,
turnOn ? "turn_on" : "turn_off"), cancellationToken);
}
public virtual Task ExecuteSleepAsync(DeviceSceneContext context, PersonalizedSceneConfig config,
string constitutionCode, CancellationToken cancellationToken) => Task.CompletedTask;
public virtual IEnumerable<DeviceCommand> GetShutdownCommands() => [];
public virtual HomeAssistantScenePresetItem? BuildPreview(PersonalizedSceneConfig config, string constitutionCode) => null;
protected HomeAssistantScenePresetItem Preview(string action, string detail,
Dictionary<string, object?>? parameters = null) => new()
{
DeviceName = DisplayName, Category = Category, EntityId = PrimaryEntityId,
Action = action, Detail = detail, Parameters = parameters ?? new()
};
}
@@ -0,0 +1,114 @@
using Evergreen.Health.Application.Health.Devices.Audio;
using Evergreen.Health.Application.Health.Devices.Contracts;
using Evergreen.Health.Application.Health.Devices.Environment;
using Evergreen.Health.Application.Health.Devices.Climate;
using Evergreen.Health.Application.Health.Devices.Curtain;
using Evergreen.Health.Application.Health.Devices.Discovery;
using Evergreen.Health.Application.Health.Devices.Dehumidifier;
using Evergreen.Health.Application.Health.Devices.Fragrance;
using Evergreen.Health.Application.Health.Devices.Lighting;
using Evergreen.Health.Application.Health.Devices.Outlet;
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 Evergreen.Health.Application.Health.Integrations.Tuya;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
using Evergreen.Health.Application.Health.Integrations.Ib43;
namespace Evergreen.Health.Application.Health.Devices;
public static class HealthDeviceRegistration
{
public static IServiceCollection AddHealthDevices(this IServiceCollection services)
{
services.AddSingleton(TimeProvider.System);
services.AddOptions<Ib43Options>();
services.AddSingleton<Ib43Parser>();
services.AddSingleton<Ib43StateStore>();
services.AddScoped(provider => new Ib43DataRepository(
provider.GetRequiredService<SqlSugar.ISqlSugarClient>().AsTenant().GetConnectionScope(SqlSugarConst.MainConfigId)));
services.AddSingleton<IIb43ReportSink, Ib43ReportSink>();
services.AddSingleton<IIb43MqttTransportFactory, Ib43MqttTransportFactory>();
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>());
services.AddHostedService<Ib43MqttService>();
services.AddSingleton(new DeviceExecutionOptions());
// 仅注册扩展契约,不启动传感器轮询、上床流程或占位音频播放。
services.AddSingleton(new SleepWorkflowOptions());
services.AddSingleton<GuidanceCatalog>();
services.AddSingleton<ISleepObservationSource, ReservedJe01Source>();
services.AddSingleton<IGuidancePlayer, ReservedGuidancePlayer>();
services.AddSingleton<ITuyaGateway, ReservedTuyaGateway>();
services.AddTransient<ReservedSleepWorkflow>();
services.AddTransient<AnomalyReminderGate>();
services.AddSingleton<IHomeAssistantGateway>(provider => new HomeAssistantClient(
provider.GetRequiredService<IHttpClientFactory>().CreateClient(nameof(HomeAssistantClient)),
provider.GetRequiredService<IOptions<HomeAssistantOptions>>()));
services.AddSingleton<IAudioPlaybackGateway>(provider => new MiAirXClient(
provider.GetRequiredService<IHttpClientFactory>().CreateClient(nameof(MiAirXClient)),
provider.GetRequiredService<IOptions<MiAirXOptions>>()));
services.AddSingleton<DeviceActionRunner>();
services.AddSingleton<SceneLightSafetyState>();
services.AddSingleton<IDeviceActionSafetyRule, PrimaryLightStateRule>();
services.AddSingleton<IndoorEnvironmentReader>();
services.AddSingleton<EnvironmentModule>();
services.AddSingleton<IHealthDeviceModule>(provider => provider.GetRequiredService<EnvironmentModule>());
services.AddSingleton<ClimateModule>();
services.AddSingleton<IHealthDeviceModule>(provider => provider.GetRequiredService<ClimateModule>());
services.AddSingleton<CurtainModule>();
services.AddSingleton<IHealthDeviceModule>(provider => provider.GetRequiredService<CurtainModule>());
services.AddSingleton<OutletModule>();
services.AddSingleton<TcmMusicTransitionService>();
services.AddSingleton<ITcmMusicRuntime>(provider => provider.GetRequiredService<TcmMusicTransitionService>());
services.AddHostedService(provider => provider.GetRequiredService<TcmMusicTransitionService>());
services.AddSingleton<HomeAssistantLightTransitionService>();
services.AddSingleton<ILightTransitionRuntime>(provider => provider.GetRequiredService<HomeAssistantLightTransitionService>());
services.AddHostedService(provider => provider.GetRequiredService<HomeAssistantLightTransitionService>());
services.AddSingleton<DehumidifierModule>();
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(
provider.GetRequiredService<IOptions<HomeAssistantOptions>>().Value.Entities.AmbientLight),
provider.GetRequiredService<LightOffDispatcher>()));
services.AddSingleton<AudioModule>();
services.AddSingleton<IHealthDeviceModule>(provider => provider.GetRequiredService<AudioModule>());
services.AddSingleton<LightOffDispatcher>();
services.AddSingleton<DeviceModuleRegistry>(provider =>
{
var options = provider.GetRequiredService<IOptions<HomeAssistantOptions>>().Value;
var modules = provider.GetServices<IHealthDeviceModule>().Where(module => module.EntityIds.Any()).ToList();
if (!string.IsNullOrWhiteSpace(options.Entities.Outlet)) modules.Add(provider.GetRequiredService<OutletModule>());
if (!string.IsNullOrWhiteSpace(options.Entities.AmbientLight)) modules.Add(provider.GetRequiredService<LightingModule>());
var lights = provider.GetRequiredService<HomeAssistantLightTransitionService>();
var runner = provider.GetRequiredService<DeviceActionRunner>();
modules.AddRange(options.AdditionalSleepLights.Select(light => new ConfiguredSleepLightModule(runner, light, lights.ForEntity(light.EntityId))));
return new DeviceModuleRegistry(modules);
});
services.AddSingleton<IStandardDeviceControl, StandardLightControl>();
services.AddSingleton<DeviceParameterController>();
services.AddSingleton<HomeAssistantDeviceCatalog>();
services.AddSingleton<HealthSceneCoordinator>();
return services;
}
}
@@ -0,0 +1,37 @@
using Evergreen.Health.Application.Health.Integrations.HomeAssistant;
using Evergreen.Health.Application.Health.Scenes;
using Evergreen.Health.Application.Health.Sleep;
using Microsoft.Extensions.Options;
namespace Evergreen.Health.Application.Health.Devices.Lighting;
public sealed class BedPresenceSleepLightAction(HomeAssistantLightTransitionService lights, DeviceActionRunner runner,
DeviceExecutionOptions execution, IOptions<HomeAssistantOptions> options) : IBedPresenceAction
{
public string Key => "light-sleep";
public string Description => "启动助眠渐暗";
public bool SupportsTarget(string entityId) => options.Value.Enabled && entityId?.StartsWith("light.", StringComparison.Ordinal) == true &&
(entityId == options.Value.Entities.AmbientLight || options.Value.AdditionalSleepLights.Any(light => light.EntityId == entityId));
public async Task<string> ExecuteAsync(string entityId, CancellationToken token)
{
if (!SupportsTarget(entityId)) return "target-not-configured";
var transition = await lights.StartSleepIfIdleAsync(entityId, options.Value.SleepScene.LightColorTemperatureKelvin, token);
if (transition == null) return "already-running";
for (var attempt = 0; attempt < execution.VerificationAttempts; attempt++)
{
await runner.DelayAsync(execution.VerificationDelay, token);
try
{
var state = await runner.ReadAvailableStateAsync(entityId, token);
var brightness = state.GetDoubleAttribute("brightness");
if (state.State == "on" && brightness is double value &&
Math.Abs(value / 255 * 100 - transition.BrightnessPercent) <= 1.5)
return "readback-confirmed";
}
catch (InvalidOperationException) { /* 实体暂未回报,下一轮只重读状态。 */ }
}
return "sent-readback-unconfirmed";
}
}
@@ -0,0 +1,60 @@
using Evergreen.Health.Application.Health.Devices.Contracts;
using Evergreen.Health.Application.Health.Integrations.HomeAssistant;
using Evergreen.Health.Application.Health.Models;
using Evergreen.Health.Application.Health.Scenes;
namespace Evergreen.Health.Application.Health.Devices.Lighting;
/// <summary>额外场景灯复用标准参数和单灯渐变;型号专属模式通过配置选择,不套用原灯的关灯事件/唤醒钩子。</summary>
public sealed class ConfiguredSleepLightModule : HealthDeviceModule
{
private readonly SceneLightOptions _light;
private readonly ILightTransitionRuntime _transition;
public ConfiguredSleepLightModule(DeviceActionRunner runner, SceneLightOptions light, ILightTransitionRuntime transition) : base(runner)
=> (_light, _transition) = (light, transition);
public override string Key => _light.Key;
public override string Category => nameof(HealthDeviceCategory.Lighting);
public override string DisplayName => _light.Name;
public override string PrimaryEntityId => _light.EntityId;
public override IReadOnlyList<DeviceParameterDefinition> Parameters => [];
public override async Task ExecuteParameterAsync(DeviceCommand command, CancellationToken token)
{
await _transition.CancelAsync(token);
await base.ExecuteParameterAsync(command, token);
}
public override async Task SetPowerAsync(bool turnOn, CancellationToken token)
{
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);
}
public override async Task ExecuteSleepAsync(DeviceSceneContext context, PersonalizedSceneConfig config,
string constitutionCode, CancellationToken token)
{
if (!context.States.TryGetValue(PrimaryEntityId, out var state) || !DeviceActionRunner.IsAvailable(state))
{
context.Results.Add(new() { Name = $"开启{DisplayName}助眠渐暗", EntityId = PrimaryEntityId,
Status = "Skipped", Message = "场景灯当前离线或不可用" });
return;
}
await context.ExternalAsync($"开启{DisplayName}助眠渐暗", PrimaryEntityId,
async cancellation => { await _transition.StartSleepAsync(config.LightColorTemperatureKelvin, cancellation); }, token);
}
public override IEnumerable<DeviceCommand> GetShutdownCommands()
=> [new($"关闭{DisplayName}", PrimaryEntityId, "light", "turn_off", ExpectedState: "off")];
public override HomeAssistantScenePresetItem? BuildPreview(PersonalizedSceneConfig config, string constitutionCode)
=> Preview("开启助眠渐暗灯光", $"{_light.SleepEffect ?? "助眠渐暗"} · 从50%开始,每5秒降低1%,最终关灯",
new() { ["brightness"] = "50%→关闭", ["effect"] = _light.SleepEffect });
}
@@ -0,0 +1,80 @@
using Evergreen.Health.Application.Health.Devices.Contracts;
using Evergreen.Health.Application.Health.Integrations.HomeAssistant;
using Evergreen.Health.Application.Health.Models;
using Evergreen.Health.Application.Health.Scenes;
using Microsoft.Extensions.Options;
namespace Evergreen.Health.Application.Health.Devices.Lighting;
public sealed class LightingModule : HealthDeviceModule
{
private readonly HomeAssistantEntityOptions _entities;
private readonly ILightTransitionRuntime _transition;
private readonly LightOffDispatcher _lightOff;
public LightingModule(DeviceActionRunner runner, IOptions<HomeAssistantOptions> options,
ILightTransitionRuntime transition, LightOffDispatcher lightOff) : base(runner)
{
_entities = options.Value.Entities; _transition = transition; _lightOff = lightOff;
Parameters =
[
new("brightness", PrimaryEntityId, value =>
{
var brightness = DeviceValues.Integer(value, 0, 100, "灯光亮度");
return new("设置灯光亮度", PrimaryEntityId, "light", brightness == 0 ? "turn_off" : "turn_on",
brightness == 0 ? null : new Dictionary<string, object?> { ["brightness_pct"] = brightness })
{ Intent = brightness == 0 ? DeviceCommandIntent.TurnRelaxationLightOff : DeviceCommandIntent.TurnRelaxationLightOn };
}, "灯光亮度", 0, 100),
new("color_temp", PrimaryEntityId, value => new("设置灯光色温", PrimaryEntityId, "light", "turn_on",
new Dictionary<string, object?> { ["color_temp_kelvin"] = DeviceValues.Integer(value, 2200, 5000, "灯光色温") })
{ Intent = DeviceCommandIntent.TurnRelaxationLightOn },
"灯光色温", 2200, 5000)
];
}
public override string Key => "lighting";
public override string Category => nameof(HealthDeviceCategory.Lighting);
public override string DisplayName => "卧室灯光";
public override string PrimaryEntityId => _entities.AmbientLight;
public override IReadOnlyList<DeviceParameterDefinition> Parameters { get; }
public override async Task ExecuteParameterAsync(DeviceCommand command, CancellationToken cancellationToken)
{
// 标准手动面板复用本灯的专属安全钩子;其他灯不会触发主灯关灯事件。
command = command with { Intent = command.Service == "turn_off"
? DeviceCommandIntent.TurnRelaxationLightOff : DeviceCommandIntent.TurnRelaxationLightOn };
await _transition.CancelAsync(cancellationToken);
await Runner.SendAsync(command, cancellationToken);
if (command.Service == "turn_off") await _lightOff.NotifyAsync(cancellationToken);
if (command.ExpectedState is not null) await Runner.VerifyParameterAsync(command, cancellationToken);
}
public override async Task SetPowerAsync(bool turnOn, CancellationToken cancellationToken)
{
if (turnOn) { await _transition.StartSleepAsync(2700, cancellationToken); return; }
await _transition.CancelAsync(cancellationToken);
await Runner.SendAsync(new("关闭助眠灯光", PrimaryEntityId, "light", "turn_off")
{ Intent = DeviceCommandIntent.TurnRelaxationLightOff }, cancellationToken);
await _lightOff.NotifyAsync(cancellationToken);
}
public override async Task ExecuteSleepAsync(DeviceSceneContext context, PersonalizedSceneConfig config,
string constitutionCode, CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(PrimaryEntityId) || !context.States.TryGetValue(PrimaryEntityId, out var state) ||
!DeviceActionRunner.IsAvailable(state))
{
context.Results.Add(new() { Name = "开启助眠渐暗灯光", EntityId = PrimaryEntityId,
Status = "Skipped", Message = "灯光未配置或离线" }); return;
}
await context.ExternalAsync("开启助眠渐暗灯光", PrimaryEntityId,
async token => { await _transition.StartSleepAsync(config.LightColorTemperatureKelvin, token); }, cancellationToken);
}
public override IEnumerable<DeviceCommand> GetShutdownCommands()
=> [new("关闭助眠灯光", PrimaryEntityId, "light", "turn_off", ExpectedState: "off")
{ Intent = DeviceCommandIntent.TurnRelaxationLightOff }];
public override HomeAssistantScenePresetItem? BuildPreview(PersonalizedSceneConfig config, string constitutionCode)
=> string.IsNullOrWhiteSpace(PrimaryEntityId) ? null : Preview("开启助眠渐暗灯光",
$"从50%开始,每5秒降低1%,最终关灯 · 色温 {config.LightColorTemperatureKelvin}K",
new() { ["brightness"] = "50%→关闭", ["color_temp"] = $"{config.LightColorTemperatureKelvin}K" });
}
@@ -0,0 +1,79 @@
using Evergreen.Health.Application.Health.Devices.Contracts;
using Evergreen.Health.Application.Health.Integrations.HomeAssistant;
using Evergreen.Health.Application.Health.Models;
using System.Text.Json;
namespace Evergreen.Health.Application.Health.Devices.Lighting;
/// <summary>标准灯光参数依赖 HA 上报能力,不能将一个型号的色温范围或模式复制给另一盏灯。</summary>
public sealed class StandardLightControl : IStandardDeviceControl
{
public IReadOnlyList<string> ParameterKeys => ["light_brightness", "light_color_temperature", "light_effect"];
private static readonly string[] DimmableModes = ["brightness", "color_temp", "hs", "rgb", "rgbw", "rgbww", "xy", "white"];
public DeviceCommand Build(HomeAssistantDiscoveredDeviceOutput device, HomeAssistantState state,
string parameterType, object? value)
{
var entityId = device.PrimaryControlEntityId ?? "";
if (!entityId.StartsWith("light.", StringComparison.Ordinal) ||
!device.Entities.Any(entity => entity.EntityId == entityId && entity.Domain == "light"))
throw new InvalidOperationException("该设备没有受信任的灯光主控能力");
Validate(state, entityId, parameterType, value);
var command = parameterType switch
{
"light_brightness" => Brightness(entityId, value),
"light_color_temperature" => new DeviceCommand("设置灯光色温", entityId, "light", "turn_on",
new Dictionary<string, object?> { ["color_temp_kelvin"] = DeviceValues.Integer(value, 1, int.MaxValue, "灯光色温") }),
"light_effect" => new DeviceCommand("设置灯光模式", entityId, "light", "turn_on",
new Dictionary<string, object?> { ["effect"] = DeviceValues.Text(value) }),
_ => throw new InvalidOperationException("不支持的标准灯光参数")
};
return command with { ValidateCurrentState = current => Validate(current, entityId, parameterType, value),
RetryOnVerificationMismatch = false };
}
private static DeviceCommand Brightness(string entityId, object? value)
{
var brightness = DeviceValues.Integer(value, 0, 100, "灯光亮度");
return new("设置灯光亮度", entityId, "light", brightness == 0 ? "turn_off" : "turn_on",
brightness == 0 ? null : new Dictionary<string, object?> { ["brightness_pct"] = brightness },
brightness == 0 ? "off" : null);
}
private static void Validate(HomeAssistantState state, string entityId, string parameterType, object? value)
{
if (state.EntityId != entityId || state.State is not ("on" or "off"))
throw new InvalidOperationException("灯光主控当前离线或状态未确认");
var modes = Strings(state, "supported_color_modes");
switch (parameterType)
{
case "light_brightness":
if (!modes.Any(mode => DimmableModes.Contains(mode, StringComparer.Ordinal)))
throw new InvalidOperationException("这盏灯未声明支持亮度调节");
DeviceValues.Integer(value, 0, 100, "灯光亮度");
break;
case "light_color_temperature":
var min = state.GetDoubleAttribute("min_color_temp_kelvin");
var max = state.GetDoubleAttribute("max_color_temp_kelvin");
if (!modes.Contains("color_temp") || min is null || max is null || min <= 0 || max <= min ||
max > int.MaxValue || min != Math.Truncate(min.Value) || max != Math.Truncate(max.Value))
throw new InvalidOperationException("这盏灯的色温能力或范围未确认");
DeviceValues.Integer(value, (int)min.Value, (int)max.Value, "灯光色温");
break;
case "light_effect":
var features = state.GetDoubleAttribute("supported_features");
if (features is null || features < 0 || features > int.MaxValue || features != Math.Truncate(features.Value) ||
(((int)features.Value & 4) == 0)) throw new InvalidOperationException("这盏灯未声明支持灯光模式");
DeviceValues.Choice(value, Strings(state, "effect_list"), "灯光模式");
break;
default: throw new InvalidOperationException("不支持的标准灯光参数");
}
}
private static string[] Strings(HomeAssistantState state, string key)
=> state.Attributes.ValueKind == JsonValueKind.Object && state.Attributes.TryGetProperty(key, out var values) &&
values.ValueKind == JsonValueKind.Array
? values.EnumerateArray().Where(value => value.ValueKind == JsonValueKind.String)
.Select(value => value.GetString()!).Where(value => !string.IsNullOrWhiteSpace(value)).Distinct(StringComparer.Ordinal).ToArray()
: [];
}
@@ -0,0 +1,44 @@
using Evergreen.Health.Application.Health.Devices.Contracts;
using Evergreen.Health.Application.Health.Integrations.HomeAssistant;
using Evergreen.Health.Application.Health.Models;
using Evergreen.Health.Application.Health.Scenes;
using Microsoft.Extensions.Options;
namespace Evergreen.Health.Application.Health.Devices.Outlet;
/// <summary>仅单独控制明确绑定的插座主开关;不使用服务开关、切换按钮或内部保护参数。</summary>
public sealed class OutletModule : HealthDeviceModule
{
private readonly string _entityId;
public OutletModule(DeviceActionRunner runner, IOptions<HomeAssistantOptions> options) : base(runner)
=> _entityId = options.Value.Entities.Outlet;
public override string Key => "outlet";
public override string Category => nameof(HealthDeviceCategory.Outlet);
public override string DisplayName => "智能插座";
public override string PrimaryEntityId => _entityId;
public override IReadOnlyList<DeviceParameterDefinition> Parameters => [];
public override async Task SetPowerAsync(bool turnOn, CancellationToken cancellationToken)
{
cancellationToken.ThrowIfCancellationRequested();
if (!_entityId.StartsWith("switch.", StringComparison.OrdinalIgnoreCase))
throw new InvalidOperationException("插座主开关尚未正确绑定");
var command = new DeviceCommand(turnOn ? "开启插座电源" : "关闭插座电源", _entityId, "switch",
turnOn ? "turn_on" : "turn_off", ExpectedState: turnOn ? "on" : "off")
{
ValidateCurrentState = state =>
{
if (!string.Equals(state.EntityId, _entityId, StringComparison.OrdinalIgnoreCase) ||
state.State is not ("on" or "off"))
throw new InvalidOperationException("插座主开关状态未确认,请刷新后再试");
},
// 等待有限次数回读,不因读数延迟再次通电,也不使用 toggle 反转电源。
RetryOnVerificationMismatch = false
};
await Runner.SendAsync(command, cancellationToken);
await Runner.VerifyParameterAsync(command, cancellationToken);
}
// 本版不接入舒睡/唤醒/一键关闭:沿用基类的无场景动作、无关闭命令、无场景预览。
}
@@ -62,6 +62,19 @@ public sealed class HealthProfile : EntityBase
[SugarColumn(ColumnDescription = "健康目标", Length = 500, IsNullable = true)] [SugarColumn(ColumnDescription = "健康目标", Length = 500, IsNullable = true)]
public string? HealthGoal { get; set; } public string? HealthGoal { get; set; }
[SugarColumn(ColumnDescription = "健康评估问卷答案JSON", ColumnDataType = "text", IsNullable = true)]
public string? AssessmentJson { get; set; }
// 保留旧版本数据,不再读写;一键启动仅读取中医四诊生成的 TcmResultJson。
[SugarColumn(ColumnDescription = "旧版设备调节参数快照JSON", ColumnDataType = "text", IsNullable = true)]
public string? CustomSceneConfigJson { get; set; }
[SugarColumn(ColumnDescription = "中医四诊答案JSON", ColumnDataType = "text", IsNullable = true)]
public string? TcmAssessmentJson { get; set; }
[SugarColumn(ColumnDescription = "中医体质倾向结果JSON", ColumnDataType = "text", IsNullable = true)]
public string? TcmResultJson { get; set; }
} }
[SugarTable("health_module_selection", "用户健康板块选择")] [SugarTable("health_module_selection", "用户健康板块选择")]
@@ -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";
}
@@ -0,0 +1,55 @@
using SqlSugar;
namespace Evergreen.Health.Application.Health.Entities;
[SugarTable("health_ib43_binding", "IB43床位用户绑定历史")]
[SugarIndex("ux_ib43_active_slot", nameof(ActiveSlot), OrderByType.Asc, IsUnique = true)]
[SugarIndex("ix_ib43_binding_user", nameof(UserId), OrderByType.Asc)]
public sealed class HealthIb43Binding
{
[SugarColumn(IsPrimaryKey = true, Length = 32)]
public string Id { get; set; } = Guid.NewGuid().ToString("N");
[SugarColumn(Length = 64)]
public string DeviceKey { get; set; } = "";
[SugarColumn(Length = 128)]
public string ProductKey { get; set; } = "";
[SugarColumn(Length = 128)]
public string SerialNumber { get; set; } = "";
[SugarColumn(Length = 5)]
public string Side { get; set; } = "";
// 活跃时为设备哈希+侧别,解绑时为 NULL。数据库唯一索引防止并发抢占。
[SugarColumn(Length = 70, IsNullable = true)]
public string ActiveSlot { get; set; }
public long UserId { get; set; }
[SugarColumn(Length = 80)]
public string BedName { get; set; } = "";
public bool Simulation { get; set; }
public long StartedAtMs { get; set; }
[SugarColumn(IsNullable = true)]
public long? EndedAtMs { get; set; }
public long AssignedBy { get; set; }
[SugarColumn(IsNullable = true)]
public long? ReleasedBy { get; set; }
}
[SugarTable("health_ib43_observation", "IB43按用户侧别保存的观测增量")]
[SugarIndex("ux_ib43_report_binding", nameof(ReportId), OrderByType.Asc, nameof(BindingId), OrderByType.Asc, IsUnique = true)]
[SugarIndex("ix_ib43_obs_owner_time", nameof(UserId), OrderByType.Asc, nameof(BindingId), OrderByType.Asc, nameof(ReceivedAtMs), OrderByType.Asc)]
public sealed class HealthIb43Observation
{
[SugarColumn(IsPrimaryKey = true, IsIdentity = true)]
public long Id { get; set; }
[SugarColumn(Length = 32)]
public string ReportId { get; set; } = "";
[SugarColumn(Length = 32)]
public string BindingId { get; set; } = "";
public long UserId { get; set; }
public long ReceivedAtMs { get; set; }
public bool HasHeartRate { get; set; }
public bool HasRespiration { get; set; }
public bool HasPresence { get; set; }
public bool HasSnoring { get; set; }
// 只含该侧有效解析字段及来源,不含另一侧、原始完整报文或凭据。
[SugarColumn(ColumnDataType = "text")]
public string ReadingsJson { get; set; } = "{}";
}
@@ -0,0 +1,14 @@
using SqlSugar;
namespace Evergreen.Health.Application.Health.Entities;
[SugarTable("health_ib43_maintenance_audit", "IB43本地维护审计")]
public sealed class HealthIb43MaintenanceAudit
{
[SugarColumn(IsPrimaryKey = true, Length = 32)] public string Id { get; set; }
[SugarColumn(Length = 32)] public string BindingId { get; set; }
public long UserId { get; set; }
[SugarColumn(Length = 200)] public string Actor { get; set; }
[SugarColumn(Length = 64)] public string Operation { get; set; }
public long OccurredAtMs { get; set; }
}
@@ -5,7 +5,7 @@ using System.Text.Json;
namespace Evergreen.Health.Application.Health.Integrations.HomeAssistant; namespace Evergreen.Health.Application.Health.Integrations.HomeAssistant;
public sealed class HomeAssistantClient public sealed class HomeAssistantClient : IHomeAssistantGateway
{ {
private readonly HttpClient _httpClient; private readonly HttpClient _httpClient;
private readonly HomeAssistantOptions _options; private readonly HomeAssistantOptions _options;
@@ -20,9 +20,47 @@ public sealed class HomeAssistantClient
public async Task<IReadOnlyList<HomeAssistantState>> GetStatesAsync(CancellationToken cancellationToken = default) public async Task<IReadOnlyList<HomeAssistantState>> GetStatesAsync(CancellationToken cancellationToken = default)
{ {
using var request = CreateRequest(HttpMethod.Get, "api/states"); using var request = CreateRequest(HttpMethod.Get, "api/states");
using var response = await _httpClient.SendAsync(request, cancellationToken); using var response = await SendAsync(request, cancellationToken);
await EnsureSuccessAsync(response, cancellationToken); await EnsureSuccessAsync(response, cancellationToken);
return await response.Content.ReadFromJsonAsync<List<HomeAssistantState>>(cancellationToken: cancellationToken) ?? []; var states = await response.Content.ReadFromJsonAsync<List<HomeAssistantState>>(cancellationToken: cancellationToken) ?? [];
await EnrichTemperatureUnitAsync(states, cancellationToken);
return states;
}
// 原生 climate 状态通常省略温度单位,它使用 HA 全局单位系统;不能把缺失直接当作摄氏。
private async Task EnrichTemperatureUnitAsync(List<HomeAssistantState> states, CancellationToken cancellationToken)
{
var targets = states.Where(state => state.EntityId.StartsWith("climate.", StringComparison.OrdinalIgnoreCase) &&
state.Attributes.ValueKind == JsonValueKind.Object &&
!state.Attributes.TryGetProperty("temperature_unit", out _)).ToArray();
if (targets.Length == 0) return;
using var bounded = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
bounded.CancelAfter(TimeSpan.FromSeconds(2));
try
{
using var request = CreateRequest(HttpMethod.Get, "api/config");
using var response = await SendAsync(request, bounded.Token);
await EnsureSuccessAsync(response, bounded.Token);
using var config = await JsonDocument.ParseAsync(await response.Content.ReadAsStreamAsync(bounded.Token),
cancellationToken: bounded.Token);
if (!config.RootElement.TryGetProperty("unit_system", out var units) ||
units.ValueKind != JsonValueKind.Object || !units.TryGetProperty("temperature", out var temperature) ||
temperature.ValueKind != JsonValueKind.String) return;
var unit = temperature.GetString();
if (unit is not ("°C" or "°F")) return;
foreach (var state in targets)
{
var attributes = state.Attributes.EnumerateObject().ToDictionary(property => property.Name,
property => property.Value.Clone(), StringComparer.Ordinal);
attributes["temperature_unit"] = JsonSerializer.SerializeToElement(unit);
state.Attributes = JsonSerializer.SerializeToElement(attributes);
}
}
catch (Exception) when (!cancellationToken.IsCancellationRequested)
{
// 单位接口不可读时仍保留其他真实状态;空调温度功能按单位未知禁用,不猜测。
}
cancellationToken.ThrowIfCancellationRequested();
} }
public async Task CallServiceAsync( public async Task CallServiceAsync(
@@ -33,10 +71,19 @@ public sealed class HomeAssistantClient
{ {
using var request = CreateRequest(HttpMethod.Post, $"api/services/{domain}/{service}"); using var request = CreateRequest(HttpMethod.Post, $"api/services/{domain}/{service}");
request.Content = JsonContent.Create(data); request.Content = JsonContent.Create(data);
using var response = await _httpClient.SendAsync(request, cancellationToken); using var response = await SendAsync(request, cancellationToken);
await EnsureSuccessAsync(response, cancellationToken); await EnsureSuccessAsync(response, cancellationToken);
} }
private async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
try { return await _httpClient.SendAsync(request, cancellationToken); }
catch (OperationCanceledException exception) when (!cancellationToken.IsCancellationRequested)
{
throw new TimeoutException("Home Assistant 请求超时,未确认操作完成", exception);
}
}
private HttpRequestMessage CreateRequest(HttpMethod method, string path) private HttpRequestMessage CreateRequest(HttpMethod method, string path)
{ {
if (!_options.Enabled) if (!_options.Enabled)
@@ -0,0 +1,85 @@
using Evergreen.Health.Application.Health.Models;
using Evergreen.Health.Application.Health.Scenes;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
namespace Evergreen.Health.Application.Health.Integrations.HomeAssistant;
/// <summary>管理明确配置的场景灯;共享单灯渐变实现,但不共享亮度、计时或取消状态。</summary>
public sealed class HomeAssistantLightTransitionService : IHostedService, ILightTransitionRuntime
{
private readonly LightTransitionWorker _primary;
private readonly Dictionary<string, LightTransitionWorker> _lights = new(StringComparer.OrdinalIgnoreCase);
public HomeAssistantLightTransitionService(IHomeAssistantGateway gateway, LightOffDispatcher lightOff,
IOptions<HomeAssistantOptions> options, ILogger<HomeAssistantLightTransitionService> logger,
SceneLightSafetyState safety, TimeProvider time)
{
_primary = new(gateway, lightOff, options, logger, safety, time);
var primaryId = options.Value.Entities.AmbientLight;
if (!string.IsNullOrWhiteSpace(primaryId)) _lights.Add(primaryId, _primary);
foreach (var light in options.Value.AdditionalSleepLights)
{
if (string.IsNullOrWhiteSpace(light.Key) || string.IsNullOrWhiteSpace(light.Name) ||
!light.EntityId.StartsWith("light.", StringComparison.Ordinal))
throw new InvalidOperationException("额外场景灯需要有效的模块键、名称和 light 实体");
if (!_lights.TryAdd(light.EntityId, new(gateway, lightOff, options, logger, safety, time, light)))
throw new InvalidOperationException($"场景灯实体重复配置:{light.EntityId}");
}
}
public ILightTransitionRuntime ForEntity(string entityId)
=> ResolveLight(entityId);
public Task<HomeAssistantLightTransitionOutput?> StartSleepIfIdleAsync(string entityId, int kelvin, CancellationToken token)
=> ResolveLight(entityId).StartSleepIfIdleAsync(kelvin, token);
private LightTransitionWorker ResolveLight(string entityId)
=> !string.IsNullOrWhiteSpace(entityId) && _lights.TryGetValue(entityId, out var light) ? light
: throw new InvalidOperationException("该灯未配置为场景灯");
public HomeAssistantLightTransitionOutput GetStatus()
{
var output = _primary.GetStatus();
output.Lights = _lights.Values.Select(light => light.GetStatus()).ToList();
return output;
}
// 旧助眠/晨起接口只作用于原卧室灯;额外灯由各自场景模块调用。
public async Task<HomeAssistantLightTransitionOutput> StartSleepAsync(int kelvin, CancellationToken token)
{
EnsurePrimaryConfigured();
await _primary.StartSleepAsync(kelvin, token);
return GetStatus();
}
public async Task<HomeAssistantLightTransitionOutput> StartWakeAsync(CancellationToken token)
{
EnsurePrimaryConfigured();
await _primary.StartWakeAsync(token);
return GetStatus();
}
public async Task<HomeAssistantLightTransitionOutput> CancelPrimaryAsync(CancellationToken token)
{
EnsurePrimaryConfigured();
await _primary.CancelAsync(token);
return GetStatus();
}
private void EnsurePrimaryConfigured()
{
if (string.IsNullOrWhiteSpace(_primary.GetStatus().EntityId)) throw new InvalidOperationException("卧室灯光未配置");
}
// 一键关闭/场景替换必须取消全部灯的计时任务,防止后续 tick 重新点亮灯。
public async Task<HomeAssistantLightTransitionOutput> CancelAsync(CancellationToken token)
{
foreach (var light in _lights.Values) await light.CancelAsync(token);
return GetStatus();
}
public Task StartAsync(CancellationToken token) => Task.CompletedTask;
public async Task StopAsync(CancellationToken token)
{
foreach (var light in _lights.Values) await light.StopAsync(token);
}
}
@@ -1,4 +1,5 @@
using System.Text.Json; using System.Text.Json;
using System.Globalization;
using System.Text.Json.Serialization; using System.Text.Json.Serialization;
namespace Evergreen.Health.Application.Health.Integrations.HomeAssistant; namespace Evergreen.Health.Application.Health.Integrations.HomeAssistant;
@@ -17,17 +18,20 @@ public sealed class HomeAssistantState
[JsonPropertyName("last_updated")] [JsonPropertyName("last_updated")]
public DateTimeOffset? LastUpdated { get; set; } public DateTimeOffset? LastUpdated { get; set; }
[JsonPropertyName("last_reported")]
public DateTimeOffset? LastReported { get; set; }
public string? GetStringAttribute(string name) public string? GetStringAttribute(string name)
{ {
if (!Attributes.TryGetProperty(name, out var value)) return null; if (Attributes.ValueKind != JsonValueKind.Object || !Attributes.TryGetProperty(name, out var value)) return null;
return value.ValueKind == JsonValueKind.String ? value.GetString() : value.ToString(); return value.ValueKind == JsonValueKind.String ? value.GetString() : value.ToString();
} }
public double? GetDoubleAttribute(string name) public double? GetDoubleAttribute(string name)
{ {
if (!Attributes.TryGetProperty(name, out var value)) return null; if (Attributes.ValueKind != JsonValueKind.Object || !Attributes.TryGetProperty(name, out var value)) return null;
if (value.ValueKind == JsonValueKind.Number && value.TryGetDouble(out var d)) return d; if (value.ValueKind == JsonValueKind.Number && value.TryGetDouble(out var d) && double.IsFinite(d)) return d;
if (double.TryParse(value.ToString(), out var parsed)) return parsed; if (double.TryParse(value.ToString(), NumberStyles.Float, CultureInfo.InvariantCulture, out var parsed) && double.IsFinite(parsed)) return parsed;
return null; return null;
} }
} }
@@ -54,6 +58,9 @@ public sealed class HomeAssistantDeviceRegistryItem
[JsonPropertyName("disabled_by")] [JsonPropertyName("disabled_by")]
public string? DisabledBy { get; set; } public string? DisabledBy { get; set; }
[JsonPropertyName("entry_type")]
public string? EntryType { get; set; }
} }
public sealed class HomeAssistantEntityRegistryItem public sealed class HomeAssistantEntityRegistryItem
@@ -8,17 +8,58 @@ public sealed class HomeAssistantOptions : IConfigurableOptions
public string BaseUrl { get; set; } = string.Empty; public string BaseUrl { get; set; } = string.Empty;
public string AccessToken { get; set; } = string.Empty; public string AccessToken { get; set; } = string.Empty;
public int TimeoutSeconds { get; set; } = 15; public int TimeoutSeconds { get; set; } = 15;
// 匹配已退役设备的明确实体绑定,不按名称或品牌隐藏设备。
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 HomeAssistantEntityOptions Entities { get; set; } = new();
public HomeAssistantSleepSceneOptions SleepScene { get; set; } = new(); public HomeAssistantSleepSceneOptions SleepScene { get; set; } = new();
public List<SceneLightOptions> AdditionalSleepLights { get; set; } = [];
}
public sealed class DisplayOnlyDeviceOptions
{
public string EntityId { get; set; } = string.Empty;
public string Name { get; set; } = string.Empty;
public string Category { get; set; } = "Other";
}
/// <summary>明确加入舒睡/全关的额外灯具;未配置的灯仍仅提供单独控制。</summary>
public sealed class SceneLightOptions
{
public string Key { get; set; } = string.Empty;
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 public sealed class HomeAssistantEntityOptions
{ {
public string Climate { get; set; } = string.Empty;
public string Curtain { get; set; } = string.Empty;
public string Outlet { get; set; } = string.Empty;
public string Dehumidifier { get; set; } = string.Empty; public string Dehumidifier { get; set; } = string.Empty;
public string DehumidifierFanLevel { get; set; } = string.Empty; public string DehumidifierFanLevel { get; set; } = string.Empty;
public string DehumidifierUv { get; set; } = string.Empty; public string DehumidifierUv { get; set; } = string.Empty;
public string FragranceSwitch { get; set; } = string.Empty; public string FragranceSwitch { get; set; } = string.Empty;
public string FragranceLight { get; set; } = string.Empty; public string FragranceLight { get; set; } = string.Empty;
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;
public string AmbientLight { get; set; } = string.Empty; public string AmbientLight { get; set; } = string.Empty;
public string Speaker { get; set; } = string.Empty; public string Speaker { get; set; } = string.Empty;
public string SpeakerSleepMode { get; set; } = string.Empty; public string SpeakerSleepMode { get; set; } = string.Empty;
@@ -45,7 +45,7 @@ public sealed class HomeAssistantRegistryService : ISingleton
} }
return _cachedRegistries; return _cachedRegistries;
} }
catch (Exception ex) catch (Exception ex) when (ex is not OperationCanceledException || !cancellationToken.IsCancellationRequested)
{ {
_logger.LogWarning("通过 WebSocket 读取 Home Assistant 注册表失败:{Message}", ex.Message); _logger.LogWarning("通过 WebSocket 读取 Home Assistant 注册表失败:{Message}", ex.Message);
return _cachedRegistries; return _cachedRegistries;
@@ -119,7 +119,7 @@ public sealed class HomeAssistantRegistryService : ISingleton
try try
{ {
await ws.CloseAsync(WebSocketCloseStatus.NormalClosure, "Completed", CancellationToken.None); await ws.CloseAsync(WebSocketCloseStatus.NormalClosure, "Completed", cts.Token);
} }
catch catch
{ {
@@ -134,7 +134,7 @@ public sealed class HomeAssistantRegistryService : ISingleton
CachedAt = DateTimeOffset.UtcNow CachedAt = DateTimeOffset.UtcNow
}; };
} }
catch (Exception ex) catch (Exception ex) when (ex is not OperationCanceledException || !cancellationToken.IsCancellationRequested)
{ {
_logger.LogWarning("连接 Home Assistant WebSocket 发生异常:{Message}", ex.Message); _logger.LogWarning("连接 Home Assistant WebSocket 发生异常:{Message}", ex.Message);
return null; return null;
@@ -0,0 +1,8 @@
namespace Evergreen.Health.Application.Health.Integrations.HomeAssistant;
public interface IHomeAssistantGateway
{
Task<IReadOnlyList<HomeAssistantState>> GetStatesAsync(CancellationToken cancellationToken = default);
Task CallServiceAsync(string domain, string service, IReadOnlyDictionary<string, object?> data,
CancellationToken cancellationToken = default);
}
@@ -0,0 +1,11 @@
using Evergreen.Health.Application.Health.Models;
namespace Evergreen.Health.Application.Health.Integrations.HomeAssistant;
public interface ILightTransitionRuntime
{
HomeAssistantLightTransitionOutput GetStatus();
Task<HomeAssistantLightTransitionOutput> StartSleepAsync(int colorTemperatureKelvin, CancellationToken cancellationToken);
Task<HomeAssistantLightTransitionOutput> StartWakeAsync(CancellationToken cancellationToken);
Task<HomeAssistantLightTransitionOutput> CancelAsync(CancellationToken cancellationToken);
}
@@ -0,0 +1,321 @@
using Evergreen.Health.Application.Health.Models;
using Evergreen.Health.Application.Health.Scenes;
using Evergreen.Health.Application.Health.Devices.Lighting;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using System.Text.Json;
namespace Evergreen.Health.Application.Health.Integrations.HomeAssistant;
/// <summary>单灯渐变执行器;每盏灯的计时、取消和状态互相隔离。</summary>
internal sealed class LightTransitionWorker : ILightTransitionRuntime
{
private const int SleepStartPercent = 50;
private const int WakeTargetPercent = 70;
private static readonly TimeSpan StepInterval = TimeSpan.FromSeconds(5);
private readonly IHomeAssistantGateway _homeAssistant;
private readonly LightOffDispatcher _lightOff;
private readonly SceneLightSafetyState _safety;
private readonly TimeProvider _time;
private readonly string _entityId;
private readonly string? _sleepEffect;
private readonly bool _isPrimaryLight;
private readonly ILogger<HomeAssistantLightTransitionService> _logger;
private readonly SemaphoreSlim _operationLock = new(1, 1);
private readonly object _statusLock = new();
private CancellationTokenSource? _activeCancellation;
private Task? _activeTask;
private HomeAssistantLightTransitionOutput _status = new();
public LightTransitionWorker(
IHomeAssistantGateway homeAssistant,
LightOffDispatcher lightOff,
IOptions<HomeAssistantOptions> options,
ILogger<HomeAssistantLightTransitionService> logger,
SceneLightSafetyState safety,
TimeProvider time, SceneLightOptions? light = null)
{
_entityId = light?.EntityId ?? options.Value.Entities.AmbientLight;
_sleepEffect = light?.SleepEffect;
_isPrimaryLight = light is null;
_logger = logger;
_homeAssistant = homeAssistant;
_lightOff = lightOff;
_safety = safety;
_time = time;
}
public async Task StopAsync(CancellationToken cancellationToken)
{
await _operationLock.WaitAsync(cancellationToken);
try { await StopCurrentAsync(); }
finally { _operationLock.Release(); }
}
public HomeAssistantLightTransitionOutput GetStatus()
{
lock (_statusLock)
{
return new HomeAssistantLightTransitionOutput
{
EntityId = _entityId,
Success = _status.Success,
Running = _status.Running,
Mode = _status.Mode,
BrightnessPercent = _status.BrightnessPercent,
TargetPercent = _status.TargetPercent,
Message = _status.Message
};
}
}
public async Task<HomeAssistantLightTransitionOutput> StartSleepAsync(int colorTemperatureKelvin, CancellationToken cancellationToken)
=> (await StartSleepCoreAsync(colorTemperatureKelvin, onlyIfIdle: false, cancellationToken))!;
internal Task<HomeAssistantLightTransitionOutput?> StartSleepIfIdleAsync(int colorTemperatureKelvin, CancellationToken cancellationToken)
=> StartSleepCoreAsync(colorTemperatureKelvin, onlyIfIdle: true, cancellationToken);
private async Task<HomeAssistantLightTransitionOutput?> StartSleepCoreAsync(int colorTemperatureKelvin, bool onlyIfIdle,
CancellationToken cancellationToken)
{
await _operationLock.WaitAsync(cancellationToken);
try
{
if (onlyIfIdle && GetStatus().Running) return null;
await StopCurrentAsync();
var light = await ReadAvailableLightAsync(cancellationToken);
var data = BuildSleepStartData(light, colorTemperatureKelvin);
await _homeAssistant.CallServiceAsync("light", "turn_on", data, cancellationToken);
if (_isPrimaryLight) _safety.MarkOn();
SetStatus(true, true, "sleep", SleepStartPercent, 0, "助眠渐暗已启动,每5秒降低1%亮度");
Launch("sleep", SleepStartPercent);
return GetStatus();
}
finally { _operationLock.Release(); }
}
public async Task<HomeAssistantLightTransitionOutput> StartWakeAsync(CancellationToken cancellationToken)
{
await _operationLock.WaitAsync(cancellationToken);
try
{
await StopCurrentAsync();
var light = await ReadAvailableLightAsync(cancellationToken);
var knownOffAt = _isPrimaryLight ? _safety.OffAt : null;
var isOff = string.Equals(light.State, "off", StringComparison.OrdinalIgnoreCase) ||
(knownOffAt is not null && (light.LastUpdated is null || light.LastUpdated <= knownOffAt));
var brightness = light.GetDoubleAttribute("brightness");
if (!isOff && brightness is null)
throw new InvalidOperationException("灯具未返回当前亮度,无法从当前亮度启动晨起唤醒");
var current = isOff ? 1 : Math.Clamp((int)Math.Round(brightness!.Value / 255d * 100), 1, 100);
if (current >= WakeTargetPercent && !isOff)
{
if (_isPrimaryLight) _safety.MarkOn();
SetStatus(true, false, "wake", current, WakeTargetPercent, "当前亮度已达到70%或更高,保持原亮度");
return GetStatus();
}
if (isOff)
{
await _homeAssistant.CallServiceAsync("light", "turn_on", new Dictionary<string, object?>
{
["entity_id"] = LightEntityId,
["brightness_pct"] = 1
}, cancellationToken);
}
if (_isPrimaryLight) _safety.MarkOn();
SetStatus(true, true, "wake", current, WakeTargetPercent, "晨起唤醒已启动,每5秒增加1%亮度");
Launch("wake", current);
return GetStatus();
}
finally { _operationLock.Release(); }
}
public async Task<HomeAssistantLightTransitionOutput> CancelAsync(CancellationToken cancellationToken)
{
await _operationLock.WaitAsync(cancellationToken);
try
{
await StopCurrentAsync();
var brightness = GetStatus().BrightnessPercent;
SetStatus(true, false, "none", brightness, 0, "灯光渐变已停止");
return GetStatus();
}
finally { _operationLock.Release(); }
}
private string LightEntityId => !string.IsNullOrWhiteSpace(_entityId)
? _entityId
: throw new InvalidOperationException("卧室灯光实体尚未配置");
private Dictionary<string, object?> BuildSleepStartData(HomeAssistantState state, int kelvin)
{
var data = new Dictionary<string, object?> { ["entity_id"] = LightEntityId, ["brightness_pct"] = SleepStartPercent };
// 原绑定灯兼容旧 HA 能力回读;新场景灯必须经过同一套标准能力校验。
JsonElement modes = default;
var hasModes = state.Attributes.ValueKind == JsonValueKind.Object &&
state.Attributes.TryGetProperty("supported_color_modes", out modes) && modes.ValueKind == JsonValueKind.Array;
if (_isPrimaryLight && !hasModes)
{
data["color_temp_kelvin"] = Math.Clamp(kelvin, 2200, 5000);
return data;
}
var device = new HomeAssistantDiscoveredDeviceOutput
{
PrimaryControlEntityId = LightEntityId,
Entities = [new() { EntityId = LightEntityId, Domain = "light" }]
};
var controls = new StandardLightControl();
controls.Build(device, state, "light_brightness", SleepStartPercent);
if (hasModes && modes.EnumerateArray().Any(mode => mode.ValueKind == JsonValueKind.String && mode.GetString() == "color_temp"))
{
var min = state.GetDoubleAttribute("min_color_temp_kelvin");
var max = state.GetDoubleAttribute("max_color_temp_kelvin");
if (min is null || max is null || min <= 0 || max <= min || max > int.MaxValue ||
min != Math.Truncate(min.Value) || max != Math.Truncate(max.Value))
throw new InvalidOperationException("场景灯色温范围未确认,未发送启动指令");
var target = Math.Clamp(kelvin, (int)min.Value, (int)max.Value);
controls.Build(device, state, "light_color_temperature", target);
data["color_temp_kelvin"] = target;
}
if (!string.IsNullOrWhiteSpace(_sleepEffect))
{
controls.Build(device, state, "light_effect", _sleepEffect);
data["effect"] = _sleepEffect;
}
return data;
}
private async Task<HomeAssistantState> ReadAvailableLightAsync(CancellationToken cancellationToken)
{
var light = (await _homeAssistant.GetStatesAsync(cancellationToken))
.FirstOrDefault(item => string.Equals(item.EntityId, LightEntityId, StringComparison.OrdinalIgnoreCase));
if (light is null || light.State is not ("on" or "off"))
throw new InvalidOperationException("卧室灯光当前离线或不可用");
return light;
}
private void Launch(string mode, int initialPercent)
{
_activeCancellation = new CancellationTokenSource();
_activeTask = RunAsync(mode, initialPercent, _activeCancellation.Token);
}
private async Task RunAsync(string mode, int initialPercent, CancellationToken cancellationToken)
{
var current = initialPercent;
try
{
while (true)
{
await Task.Delay(StepInterval, _time, cancellationToken);
var light = await ReadAvailableLightAsync(cancellationToken);
if (!string.Equals(light.State, "on", StringComparison.OrdinalIgnoreCase))
{
if (_isPrimaryLight) _safety.MarkOff(_time.GetUtcNow());
if (mode == "sleep" && _isPrimaryLight)
{
try
{
await _lightOff.NotifyAsync(cancellationToken);
SetStatus(true, false, mode, 0, 0, "灯已关闭,渐变已停止");
}
catch (Exception exception) when (exception is not OperationCanceledException)
{
_logger.LogWarning(exception, "灯已关闭,但关灯事件处理失败");
SetStatus(false, false, mode, 0, 0, exception.Message);
}
}
else SetStatus(true, false, mode, 0, mode == "wake" ? WakeTargetPercent : 0, "灯已从其他位置关闭,渐变已停止");
return;
}
var brightness = light.GetDoubleAttribute("brightness");
if (brightness is null)
{
SetStatus(false, false, mode, current, mode == "wake" ? WakeTargetPercent : 0, "灯具未返回当前亮度,渐变已停止");
return;
}
var observed = Math.Clamp((int)Math.Round(brightness.Value / 255d * 100), 1, 100);
if (Math.Abs(observed - current) > 2)
{
SetStatus(false, false, mode, observed, mode == "wake" ? WakeTargetPercent : 0, "灯光亮度已从其他位置修改,渐变已停止");
return;
}
if (mode == "sleep" && current == 1)
{
await _homeAssistant.CallServiceAsync("light", "turn_off", new Dictionary<string, object?>
{
["entity_id"] = LightEntityId
}, cancellationToken);
if (_isPrimaryLight) _safety.MarkOff(_time.GetUtcNow());
SetStatus(true, false, mode, 0, 0, "助眠渐暗已完成,灯光已关闭");
try
{
if (_isPrimaryLight) await _lightOff.NotifyAsync(cancellationToken);
SetStatus(true, false, mode, 0, 0, "助眠渐暗已完成,灯光已关闭");
}
catch (Exception exception) when (exception is not OperationCanceledException)
{
_logger.LogWarning(exception, "助眠渐暗已完成,但关灯事件处理失败");
SetStatus(false, false, mode, 0, 0, exception.Message);
}
return;
}
var next = mode == "sleep" ? current - 1 : current + 1;
await _homeAssistant.CallServiceAsync("light", "turn_on", new Dictionary<string, object?>
{
["entity_id"] = LightEntityId,
["brightness_pct"] = next
}, cancellationToken);
current = next;
var finished = mode == "wake" && current >= WakeTargetPercent;
SetStatus(true, !finished, mode, current, mode == "wake" ? WakeTargetPercent : 0,
finished ? "晨起唤醒已完成,灯光保持70%亮度" : mode == "wake" ? "晨起唤醒进行中" : "助眠渐暗进行中");
if (finished) return;
}
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) { }
catch (Exception exception)
{
_logger.LogWarning(exception, "卧室灯光渐变任务中断");
SetStatus(false, false, mode, current, mode == "wake" ? WakeTargetPercent : 0,
$"灯光渐变中断:{exception.Message}");
}
}
private async Task StopCurrentAsync()
{
if (_activeCancellation is null) return;
_activeCancellation.Cancel();
if (_activeTask is not null) await _activeTask;
_activeCancellation.Dispose();
_activeCancellation = null;
_activeTask = null;
SetStatus(true, false, "none", GetStatus().BrightnessPercent, 0, "灯光渐变已停止");
}
private void SetStatus(bool success, bool running, string mode, int brightness, int target, string message)
{
lock (_statusLock)
{
_status = new HomeAssistantLightTransitionOutput
{
EntityId = _entityId,
Success = success,
Running = running,
Mode = mode,
BrightnessPercent = brightness,
TargetPercent = target,
Message = message
};
}
}
}
@@ -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());
}
}
@@ -0,0 +1,12 @@
using Microsoft.Extensions.Configuration;
namespace Evergreen.Health.Application.Health.Integrations.Ib43;
public static class Ib43Configuration
{
// 在框架的 JSON 扫描和常规绑定后执行,仅覆盖明确提供的 IB43 环境变量。
public static void ApplyEnvironmentOverrides(Ib43Options options)
{
new ConfigurationBuilder().AddEnvironmentVariables("Ib43__").Build().Bind(options);
}
}
@@ -0,0 +1,135 @@
using Evergreen.Health.Application.Health.Entities;
using Furion.FriendlyException;
using SqlSugar;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
namespace Evergreen.Health.Application.Health.Integrations.Ib43;
public sealed class Ib43DataRepository(ISqlSugarClient db)
{
public static string DeviceKey(string productKey, string serialNumber) =>
Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(productKey + "/" + serialNumber)));
public Task<bool> UserEnabled(long userId) => db.Queryable<HealthUser>()
.AnyAsync(u => u.Id == userId && !u.IsDelete && u.Status == HealthUserStatus.Enabled);
public async Task<bool> AdminEnabled(long userId) => await db.Queryable<SysUser>()
.Where(u => u.Id == userId && u.AccountType == AccountTypeEnum.SuperAdmin && u.Status == StatusEnum.Enable).CountAsync() > 0;
public Task<List<HealthIb43Binding>> Bindings(long userId) => db.Queryable<HealthIb43Binding>()
.Where(b => b.UserId == userId).OrderBy(b => b.StartedAtMs, OrderByType.Desc).ToListAsync();
public Task<List<HealthIb43Binding>> ActiveBindings(string productKey, string serialNumber)
{
var key = DeviceKey(productKey, serialNumber);
return db.Queryable<HealthIb43Binding>().Where(b => b.DeviceKey == key && b.EndedAtMs == null).ToListAsync();
}
public async Task<HealthIb43Binding> OwnedBinding(long userId, string id) =>
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))
throw new AppFriendlyException("该侧床位已分配,请先解绑", null) { StatusCode = 409 };
try { await db.Insertable(binding).ExecuteCommandAsync(); }
catch
{
if (await db.Queryable<HealthIb43Binding>().AnyAsync(b => b.ActiveSlot == binding.ActiveSlot))
throw new AppFriendlyException("该侧床位已分配,请先解绑", null) { StatusCode = 409 };
throw;
}
}
public async Task Release(string id, long adminId, long endedAtMs)
{
var count = await db.Updateable<HealthIb43Binding>()
.SetColumns(b => new HealthIb43Binding { ActiveSlot = null, EndedAtMs = endedAtMs, ReleasedBy = adminId })
.Where(b => b.Id == id && b.EndedAtMs == null).ExecuteCommandAsync();
if (count == 0) throw new AppFriendlyException("绑定不存在或已解绑", null) { StatusCode = 404 };
}
public async Task<Ib43SavedReport> Save(string reportId, Ib43Binding device, Ib43Report report)
{
if (!report.Accepted || report.Readings.Count == 0) return new(0, []);
var matched = 0;
var activeBeds = new List<Ib43SavedBed>();
var key = DeviceKey(device.ProductKey, device.SerialNumber);
var received = report.ReceivedAt.ToUnixTimeMilliseconds();
// 按接收时刻归属;数据库重试期间发生解绑也不转移给新用户。
var bindings = await db.Queryable<HealthIb43Binding>().Where(b => b.DeviceKey == key &&
b.Simulation == device.Simulation && b.StartedAtMs <= received &&
(b.EndedAtMs == null || b.EndedAtMs > received)).ToListAsync();
foreach (var binding in bindings)
{
var readings = report.Readings.Where(p => p.Key.StartsWith(binding.Side + ".", StringComparison.Ordinal))
.ToDictionary(p => p.Key[(binding.Side.Length + 1)..], p => p.Value);
if (readings.Count == 0) continue;
matched++;
if (binding.EndedAtMs == null) activeBeds.Add(new(binding.Id, binding.Side));
if (await db.Queryable<HealthIb43Observation>().AnyAsync(o => o.ReportId == reportId && o.BindingId == binding.Id)) continue;
var observation = new HealthIb43Observation
{
ReportId = reportId, BindingId = binding.Id, UserId = binding.UserId, ReceivedAtMs = received,
HasHeartRate = readings.ContainsKey("heartRate"), HasRespiration = readings.ContainsKey("respiration"),
HasPresence = readings.ContainsKey("presence"), HasSnoring = readings.ContainsKey("snoring"),
ReadingsJson = JsonSerializer.Serialize(readings)
};
try { await db.Insertable(observation).ExecuteCommandAsync(); }
catch
{
// 同一进程处理的报告重试幂等;没有设备序号,不能对 MQTT 重投宣称去重。
if (!await db.Queryable<HealthIb43Observation>().AnyAsync(o => o.ReportId == reportId && o.BindingId == binding.Id)) throw;
}
}
return new(matched, activeBeds);
}
public async Task<Dictionary<string, Ib43Reading>> Latest(long userId, string bindingId)
{
var output = new Dictionary<string, Ib43Reading>();
foreach (var metric in new[] { "heartRate", "respiration", "presence", "snoring" })
{
var query = db.Queryable<HealthIb43Observation>().Where(o => o.UserId == userId && o.BindingId == bindingId);
query = metric switch
{
"heartRate" => query.Where(o => o.HasHeartRate),
"respiration" => query.Where(o => o.HasRespiration),
"presence" => query.Where(o => o.HasPresence),
_ => query.Where(o => o.HasSnoring)
};
var row = await query.OrderBy(o => o.ReceivedAtMs, OrderByType.Desc).OrderBy(o => o.Id, OrderByType.Desc).FirstAsync();
if (row != null) output[metric] = JsonSerializer.Deserialize<Dictionary<string, Ib43Reading>>(row.ReadingsJson)[metric];
}
return output;
}
public Task<List<HealthIb43Observation>> History(long userId, string bindingId, long fromMs, long toMs, long? beforeId, int take)
=> db.Queryable<HealthIb43Observation>().Where(o => o.UserId == userId && o.BindingId == bindingId &&
o.ReceivedAtMs >= fromMs && o.ReceivedAtMs < toMs)
.WhereIF(beforeId.HasValue, o => o.Id < beforeId.Value)
.OrderBy(o => o.Id, OrderByType.Desc).Take(take).ToListAsync();
}
@@ -0,0 +1,55 @@
using Evergreen.Health.Application.Health.Entities;
using SqlSugar;
namespace Evergreen.Health.Application.Health.Integrations.Ib43;
// 仅由本地维护程序调用;不是 Controller,也不注册 HTTP 路由。
public sealed class Ib43LocalBinding(ISqlSugarClient db, TimeProvider clock)
{
public async Task<HealthIb43Binding> Run(string phone, Ib43Options options, string actor, bool apply)
{
var device = options.Devices.SingleOrDefault(d => d.ProductKey == "evergreen-test" && d.SerialNumber == "IB43SIM001");
if (device?.Simulation != true) throw new InvalidOperationException("模拟设备白名单不匹配,已停止。");
var users = await db.Queryable<HealthUser>().Where(u => u.Phone == phone && !u.IsDelete &&
u.Status == HealthUserStatus.Enabled).Select(u => new { u.Id }).ToListAsync();
if (users.Count != 1) throw new InvalidOperationException("未找到唯一且已启用的健康账号,已停止。");
var key = Ib43DataRepository.DeviceKey(device.ProductKey, device.SerialNumber);
var slot = key + ".left";
var existing = await db.Queryable<HealthIb43Binding>().Where(b => b.ActiveSlot == slot).FirstAsync();
if (existing != null)
{
if (existing.UserId != users[0].Id || !existing.Simulation || existing.EndedAtMs != null)
throw new InvalidOperationException("左侧已被其他用户或非模拟绑定占用,未覆盖或解绑。");
return existing;
}
var binding = new HealthIb43Binding
{
DeviceKey = key, ProductKey = device.ProductKey, SerialNumber = device.SerialNumber,
Side = "left", ActiveSlot = slot, UserId = users[0].Id, BedName = "IB43模拟床垫左侧",
Simulation = true, StartedAtMs = clock.GetUtcNow().ToUnixTimeMilliseconds(), AssignedBy = 0
};
if (!apply) return binding;
if (string.IsNullOrWhiteSpace(actor) || actor.Length > 200) throw new InvalidOperationException("维护操作者信息不合法。");
var stage = "binding";
await db.Ado.BeginTranAsync();
try
{
await db.Insertable(binding).ExecuteCommandAsync();
stage = "audit";
await db.Insertable(new HealthIb43MaintenanceAudit
{
Id = Guid.NewGuid().ToString("N"), BindingId = binding.Id, UserId = binding.UserId,
Actor = actor, OccurredAtMs = binding.StartedAtMs, Operation = "local-bind-simulator-left"
}).ExecuteCommandAsync();
stage = "commit";
await db.Ado.CommitTranAsync();
}
catch (Exception error)
{
await db.Ado.RollbackTranAsync();
var code = error is MySqlConnector.MySqlException mysql ? $" MySQL {mysql.Number}" : "";
throw new InvalidOperationException($"绑定事务失败 ({stage}, {error.GetType().Name}{code}),请重新预检确认数据库状态。", error);
}
return binding;
}
}
@@ -0,0 +1,106 @@
using Evergreen.Health.Application.Health.Sleep;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
namespace Evergreen.Health.Application.Health.Integrations.Ib43;
public sealed class Ib43MqttService(IOptions<Ib43Options> options, IIb43MqttTransportFactory factory,
Ib43StateStore store, TimeProvider clock, ILogger<Ib43MqttService> logger, IIb43ReportSink sink = null,
IBedPresenceSink presenceSink = null) : BackgroundService
{
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
var configuration = options.Value;
logger.LogInformation("IB43 MQTT startup: Enabled={Enabled}, Host={Host}, Port={Port}, PasswordConfigured={PasswordConfigured}, Devices={Count}",
configuration.Enabled, configuration.Host, configuration.Port,
!string.IsNullOrWhiteSpace(configuration.Password), configuration.Devices?.Count ?? 0);
if (!configuration.Enabled)
{
logger.LogInformation("IB43 MQTT disabled; set Ib43__Enabled=true to enable");
return;
}
// 即使直接构造(测试/其他宿主)也不带错误配置进入重试。
if (!configuration.IsValid()) throw new InvalidOperationException("Invalid Ib43 configuration");
var failures = 0;
while (!stoppingToken.IsCancellationRequested)
{
var connectedAt = clock.GetUtcNow();
var connectionId = Guid.NewGuid();
using var connection = CancellationTokenSource.CreateLinkedTokenSource(stoppingToken);
var connectionToken = connection.Token;
try
{
using var transport = factory.Create();
var seenKinds = new HashSet<string>(StringComparer.Ordinal);
int? lastMatchedBeds = null;
transport.Message += async (topic, payload, retained) =>
{
var report = store.Receive(topic, payload, retained);
if (sink != null && report.Accepted && report.Readings.Count > 0)
{
var binding = configuration.Devices.Single(d => d.PostTopic == topic);
var reportId = Guid.NewGuid().ToString("N");
Ib43SavedReport saved;
// 等待持久化再结束回调(MQTT 自动 ACK 在此之后)。失败时阻塞重试而非静默丢弃。
while (true)
{
stoppingToken.ThrowIfCancellationRequested();
try
{
saved = await sink.SaveAsync(reportId, binding, report, stoppingToken);
break;
}
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) { throw; }
catch (Exception error)
{
logger.LogWarning("IB43 persistence failed ({Type}); retrying in 5 seconds", error.GetType().Name);
await Task.Delay(TimeSpan.FromSeconds(5), clock, stoppingToken);
}
}
if (saved.MatchedCount != lastMatchedBeds)
logger.LogInformation("IB43 persistence: matched beds={Count}; zero means no user observation saved", saved.MatchedCount);
lastMatchedBeds = saved.MatchedCount;
// 发布失败与写库重试分开,联动问题不能导致重写已保存的报文。
try { Ib43PresenceAdapter.Publish(presenceSink, binding, report, saved.ActiveBeds, connectionId, connectionToken); }
catch (Exception error)
{
logger.LogWarning("IB43 presence publication failed ({Type})", error.GetType().Name);
}
}
if (!report.Accepted || report.Issues.Count > 0)
logger.LogDebug("IB43 report has {Count} parsing issues", report.Issues.Count);
if (report.Accepted && (report.Readings.Count > 0 || report.Fields.Count > 0))
foreach (var kind in report.Kinds)
if (seenKinds.Add(kind))
logger.LogInformation("IB43 first {Kind} report received in this connection", kind);
};
transport.OversizedMessage += store.RejectOversized;
store.SetTransport("connecting");
using var timeout = CancellationTokenSource.CreateLinkedTokenSource(stoppingToken);
timeout.CancelAfter(TimeSpan.FromSeconds(15));
await transport.ConnectAndSubscribeAsync(configuration, timeout.Token);
store.SetTransport("connected");
connectedAt = clock.GetUtcNow();
logger.LogInformation("IB43 MQTT connected and subscribed to {Count} configured devices", configuration.Devices.Count);
while (transport.IsConnected)
await Task.Delay(TimeSpan.FromSeconds(1), clock, stoppingToken);
}
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) { break; }
catch (Exception exception)
{
// 不写密码、连接选项、完整异常文本或原始健康报文。
logger.LogWarning("IB43 MQTT connection/subscription failed ({Type}); retrying", exception.GetType().Name);
}
finally
{
connection.Cancel();
store.SetTransport("disconnected");
}
if (clock.GetUtcNow() - connectedAt > TimeSpan.FromMinutes(1)) failures = 0;
var seconds = Math.Min(30, Math.Pow(2, Math.Min(failures++, 5)));
try { await Task.Delay(TimeSpan.FromSeconds(seconds), clock, stoppingToken); }
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) { break; }
}
}
}
@@ -0,0 +1,63 @@
using MQTTnet;
using MQTTnet.Client;
using MQTTnet.Formatter;
using MQTTnet.Protocol;
namespace Evergreen.Health.Application.Health.Integrations.Ib43;
public interface IIb43MqttTransport : IDisposable
{
bool IsConnected { get; }
event Func<string, string, bool, Task> Message;
event Action<string> OversizedMessage;
Task ConnectAndSubscribeAsync(Ib43Options options, CancellationToken cancellationToken);
}
public interface IIb43MqttTransportFactory
{
IIb43MqttTransport Create();
}
public sealed class Ib43MqttTransportFactory : IIb43MqttTransportFactory
{
public IIb43MqttTransport Create() => new Ib43MqttTransport();
}
public sealed class Ib43MqttTransport : IIb43MqttTransport
{
private readonly IMqttClient _client = new MqttFactory().CreateMqttClient();
public bool IsConnected => _client.IsConnected;
public event Func<string, string, bool, Task> Message;
public event Action<string> OversizedMessage;
public Ib43MqttTransport()
{
_client.ApplicationMessageReceivedAsync += async e =>
{
var m = e.ApplicationMessage;
// 在解码前限制长度;不要将大包全部复制进诊断缓存。
if (m.PayloadSegment.Count > Ib43StateStore.MaxPayloadBytes)
OversizedMessage?.Invoke(m.Topic);
else if (Message != null) await Message.Invoke(m.Topic, m.ConvertPayloadToString(), m.Retain);
};
}
public async Task ConnectAndSubscribeAsync(Ib43Options options, CancellationToken cancellationToken)
{
var builder = new MqttClientOptionsBuilder().WithTcpServer(options.Host, options.Port)
.WithClientId(options.ClientId).WithCredentials(options.Username, options.Password)
.WithProtocolVersion(MqttProtocolVersion.V311).WithCleanSession()
.WithKeepAlivePeriod(TimeSpan.FromSeconds(30)).WithTimeout(TimeSpan.FromSeconds(10));
if (options.UseTls) builder.WithTlsOptions(t => t.UseTls());
await _client.ConnectAsync(builder.Build(), cancellationToken);
var subscription = new MqttClientSubscribeOptionsBuilder();
foreach (var device in options.Devices)
subscription.WithTopicFilter(f => f.WithTopic(device.PostTopic)
.WithQualityOfServiceLevel(MqttQualityOfServiceLevel.AtLeastOnce));
var result = await _client.SubscribeAsync(subscription.Build(), cancellationToken);
if (result.Items.Count != options.Devices.Count || result.Items.Any(i => (int)i.ResultCode >= 128))
throw new InvalidOperationException("IB43 subscription rejected");
}
public void Dispose() => _client.Dispose();
}
@@ -0,0 +1,36 @@
namespace Evergreen.Health.Application.Health.Integrations.Ib43;
public sealed class Ib43Options
{
public bool Enabled { get; set; }
public string Host { get; set; } = "192.168.2.2";
public int Port { get; set; } = 1883;
public bool UseTls { get; set; }
public string Username { get; set; } = "evergreen-backend";
public string Password { get; set; } = "";
public string ClientId { get; set; } = "evergreen-health-backend";
public int StaleAfterSeconds { get; set; } = 150;
public List<Ib43Binding> Devices { get; set; } = [];
public bool IsValid() => !Enabled ||
(!string.IsNullOrWhiteSpace(Host) && Port is > 0 and <= 65535 &&
!string.IsNullOrWhiteSpace(Username) && !string.IsNullOrWhiteSpace(Password) &&
!string.IsNullOrWhiteSpace(ClientId) && StaleAfterSeconds > 0 &&
Devices is { Count: > 0 } && Devices.All(d => d != null &&
ValidSegment(d.ProductKey) && ValidSegment(d.SerialNumber) &&
d.PersonLeftIndex is null or 0 or 1) &&
Devices.Select(d => d.PostTopic).Distinct(StringComparer.Ordinal).Count() == Devices.Count);
private static bool ValidSegment(string value) => !string.IsNullOrWhiteSpace(value) &&
!value.Any(c => c is '/' or '+' or '#' || char.IsControl(c));
}
public sealed class Ib43Binding
{
public string ProductKey { get; set; } = "";
public string SerialNumber { get; set; } = "";
public bool Simulation { get; set; } = true;
// 实机验证前保持 null;不可从数组顺序猜左右。
public int? PersonLeftIndex { get; set; }
public string PostTopic => $"/sys/{ProductKey}/{SerialNumber}/thing/property/post";
}
@@ -0,0 +1,175 @@
using System.Text.Json;
namespace Evergreen.Health.Application.Health.Integrations.Ib43;
public sealed record Ib43Reading(double? Value, DateTimeOffset ReceivedAt, string Source);
public sealed class Ib43Report
{
public bool Accepted { get; internal set; }
public string Raw { get; init; }
public DateTimeOffset ReceivedAt { get; init; }
public List<string> Issues { get; } = [];
public Dictionary<string, Ib43Reading> Readings { get; } = new(StringComparer.Ordinal);
public Dictionary<string, JsonElement> Fields { get; } = new(StringComparer.Ordinal);
public HashSet<string> Kinds { get; } = [];
}
/// <summary>只解析协议事实;不产生控制、健康告警或睡眠分期。</summary>
public sealed class Ib43Parser
{
public Ib43Report Parse(string topic, string payload, Ib43Binding binding, DateTimeOffset receivedAt)
{
var r = new Ib43Report { Raw = payload, ReceivedAt = receivedAt };
if (topic != binding.PostTopic) return Reject(r, "topic-mismatch");
try
{
using var document = JsonDocument.Parse(payload);
var root = document.RootElement;
if (HasDuplicates(root)) return Reject(r, "duplicate-json-key");
if (root.ValueKind != JsonValueKind.Object ||
!root.TryGetProperty("method", out var method) || method.ValueKind != JsonValueKind.String ||
method.GetString() != "thing.property.post" ||
!root.TryGetProperty("params", out var p) || p.ValueKind != JsonValueKind.Object)
return Reject(r, "invalid-envelope");
if (!p.TryGetProperty("deviceName", out var sn) || sn.ValueKind != JsonValueKind.String ||
sn.GetString() != binding.SerialNumber ||
(p.TryGetProperty("productKey", out var pk) &&
(pk.ValueKind != JsonValueKind.String || pk.GetString() != binding.ProductKey)))
return Reject(r, "identity-mismatch");
if (root.TryGetProperty("version", out var version) &&
(version.ValueKind != JsonValueKind.String || version.GetString() != "1.0"))
return Reject(r, "unsupported-version");
r.Accepted = true;
foreach (var key in new[] { "firmwareVer", "characteristic", "deviceMode" })
{
if (!p.TryGetProperty(key, out var value)) continue;
r.Kinds.Add("startup");
var valid = key switch
{
"firmwareVer" => value.ValueKind == JsonValueKind.String,
"characteristic" => value.ValueKind == JsonValueKind.Object,
_ => value.ValueKind == JsonValueKind.Number && value.TryGetInt32(out _)
};
if (valid) r.Fields[key] = value.Clone(); else r.Issues.Add(key + ":invalid-type");
}
if (p.TryGetProperty("HR", out var hr))
{
r.Kinds.Add("periodic");
if (Numbers(hr, 6, "HR", r) is { } values)
{
Vitals(r, "left", values[0], values[1], values[2], "HR");
Vitals(r, "right", values[3], values[4], values[5], "HR");
}
}
if (p.TryGetProperty("airbagsPerson", out var person))
{
r.Kinds.Add("periodic");
if (Numbers(person, 2, "airbagsPerson", r) is { } values)
{
if (values.All(v => v is 0 or 1))
{
r.Fields["airbagsPerson"] = person.Clone();
if (binding.PersonLeftIndex is int index)
{
Add(r, "left.presence", values[index], "airbagsPerson");
Add(r, "right.presence", values[1 - index], "airbagsPerson");
}
}
else r.Issues.Add("airbagsPerson:invalid-enum");
}
}
foreach (var (key, length) in new[] { ("airbagsPressure", 8), ("motorHeight", 2),
("motorStatus", 4), ("motorMode", 2), ("snoreStatus", 2),
("LeftRegularTime", 10), ("RightRegularTime", 10) })
{
if (!p.TryGetProperty(key, out var value)) continue;
r.Kinds.Add("periodic");
if (Numbers(value, length, key, r) is not { } numbers) continue;
if (key == "motorStatus" && numbers.Any(n => n is not (0 or 1 or 2)))
r.Issues.Add("motorStatus:invalid-enum");
else r.Fields[key] = value.Clone();
}
if (p.TryGetProperty("adaptive", out var adaptive))
{
r.Kinds.Add("periodic");
if (adaptive.ValueKind == JsonValueKind.Array && adaptive.GetArrayLength() == 2 &&
adaptive.EnumerateArray().All(a => a.ValueKind == JsonValueKind.Object))
r.Fields["adaptive"] = adaptive.Clone();
else r.Issues.Add("adaptive:invalid-shape");
}
var sleep = Alias(p, "SleepReportNew", r);
if (sleep is { } s)
{
r.Kinds.Add("event");
if (s.ValueKind != JsonValueKind.Object) r.Issues.Add("SleepReportNew:invalid-type");
else if (Alias(s, "ibNew", r) is { } ib && Numbers(ib, 12, "ibNew", r) is { } values)
{
Presence(r, "left", values[0], "ibNew");
Presence(r, "right", values[6], "ibNew");
Vitals(r, "left", values[4], values[3], values[1], "ibNew");
Vitals(r, "right", values[10], values[9], values[7], "ibNew");
}
else r.Issues.Add("ibNew:missing-or-invalid");
}
if (r.Kinds.Count == 0) r.Issues.Add("unrecognized-report");
}
catch (JsonException) { return Reject(r, "invalid-json"); }
return r;
}
private static Ib43Report Reject(Ib43Report r, string issue)
{ r.Accepted = false; r.Issues.Add(issue); return r; }
private static bool HasDuplicates(JsonElement node) => node.ValueKind switch
{
JsonValueKind.Object => node.EnumerateObject().GroupBy(p => p.Name, StringComparer.Ordinal).Any(g => g.Count() > 1) ||
node.EnumerateObject().Any(p => HasDuplicates(p.Value)),
JsonValueKind.Array => node.EnumerateArray().Any(HasDuplicates),
_ => false
};
private static JsonElement? Alias(JsonElement p, string key, Ib43Report r)
{
var plain = p.TryGetProperty(key, out var a);
var spaced = p.TryGetProperty(" " + key, out var b);
// 双键即使内容相同也拒绝该字段,避免依赖 JSON 文本格式决定覆盖顺序。
if (plain && spaced) { r.Issues.Add(key + ":ambiguous-alias"); return null; }
if (spaced) { r.Fields["alias." + key] = JsonSerializer.SerializeToElement(" " + key); return b; }
return plain ? a : null;
}
private static double[] Numbers(JsonElement value, int length, string key, Ib43Report r)
{
if (value.ValueKind != JsonValueKind.Array || value.GetArrayLength() != length)
{ r.Issues.Add(key + ":invalid-length"); return null; }
var numbers = new double[length];
var i = 0;
foreach (var item in value.EnumerateArray())
{
if (item.ValueKind != JsonValueKind.Number || !item.TryGetDouble(out var number) || !double.IsFinite(number))
{ r.Issues.Add(key + ":invalid-number"); return null; }
numbers[i++] = number;
}
return numbers;
}
private static void Add(Ib43Report r, string key, double? value, string source)
=> r.Readings[key] = new(value, r.ReceivedAt, source);
private static void Presence(Ib43Report r, string side, double value, string source)
{
if (value is 0 or 1) Add(r, side + ".presence", value, source);
else r.Issues.Add(side + ".presence:invalid-enum");
}
private static void Vitals(Ib43Report r, string side, double hr, double respiration, double snore, string source)
{
if (hr >= 0) Add(r, side + ".heartRate", hr == 0 ? null : hr, source);
else r.Issues.Add(side + ".heartRate:negative");
if (respiration >= 0) Add(r, side + ".respiration", respiration == 0 ? null : respiration, source);
else r.Issues.Add(side + ".respiration:negative");
if (snore is 0 or 1) Add(r, side + ".snoring", snore, source);
else r.Issues.Add(side + ".snoring:invalid-enum");
}
}
@@ -0,0 +1,24 @@
using Evergreen.Health.Application.Health.Sleep;
namespace Evergreen.Health.Application.Health.Integrations.Ib43;
public sealed record Ib43SavedBed(string BindingId, string Side);
public sealed record Ib43SavedReport(int MatchedCount, IReadOnlyList<Ib43SavedBed> ActiveBeds);
internal static class Ib43PresenceAdapter
{
public static void Publish(IBedPresenceSink sink, Ib43Binding device, Ib43Report report,
IReadOnlyList<Ib43SavedBed> beds, Guid connectionId, CancellationToken connectionToken)
{
if (sink == null || !report.Accepted || report.Issues.Count != 0) return;
foreach (var bed in beds)
{
if (bed.Side is not ("left" or "right") ||
!report.Readings.TryGetValue(bed.Side + ".presence", out var presence) ||
presence.Source != "ibNew" || presence.Value is not (0 or 1)) continue;
sink.Observe(new(bed.BindingId, device.ProductKey, device.SerialNumber,
bed.Side == "left" ? BedSide.Left : BedSide.Right, device.Simulation,
presence.Value == 0 ? BedPresence.Empty : BedPresence.Occupied, presence.ReceivedAt, connectionId), connectionToken);
}
}
}
@@ -0,0 +1,16 @@
namespace Evergreen.Health.Application.Health.Integrations.Ib43;
public interface IIb43ReportSink
{
Task<Ib43SavedReport> SaveAsync(string reportId, Ib43Binding binding, Ib43Report report, CancellationToken token);
}
public sealed class Ib43ReportSink(IServiceScopeFactory scopes) : IIb43ReportSink
{
public async Task<Ib43SavedReport> SaveAsync(string reportId, Ib43Binding binding, Ib43Report report, CancellationToken token)
{
token.ThrowIfCancellationRequested();
using var scope = scopes.CreateScope();
return await scope.ServiceProvider.GetRequiredService<Ib43DataRepository>().Save(reportId, binding, report);
}
}
@@ -0,0 +1,88 @@
using Microsoft.Extensions.Options;
using System.Text.Json;
namespace Evergreen.Health.Application.Health.Integrations.Ib43;
public sealed record Ib43Field(JsonElement Value, DateTimeOffset ReceivedAt);
public sealed record Ib43Snapshot(string ProductKey, string SerialNumber, bool Simulation,
string Transport, string Freshness, DateTimeOffset? LastTelemetryAt,
IReadOnlyDictionary<string, Ib43Reading> Readings, IReadOnlyDictionary<string, Ib43Field> Fields);
public sealed record Ib43Diagnostic(string Topic, DateTimeOffset ReceivedAt, string Raw, string[] Issues);
/// <summary>进程内有界缓存。没有设备遗嘱,不能将数据超时等同于已确认设备离线。</summary>
public sealed class Ib43StateStore(IOptions<Ib43Options> options, Ib43Parser parser, TimeProvider clock)
{
public const int MaxPayloadBytes = 65536;
private readonly object _gate = new();
private readonly Dictionary<string, DeviceState> _states = new(StringComparer.Ordinal);
private readonly Queue<Ib43Diagnostic> _diagnostics = new();
private string _transport = options.Value.Enabled ? "disconnected" : "disabled";
private sealed class DeviceState
{
public DateTimeOffset? LastTelemetryAt;
public Dictionary<string, Ib43Reading> Readings { get; } = new(StringComparer.Ordinal);
public Dictionary<string, Ib43Field> Fields { get; } = new(StringComparer.Ordinal);
}
public void SetTransport(string state) { lock (_gate) _transport = state; }
public void RejectOversized(string topic)
{
lock (_gate)
{
_diagnostics.Enqueue(new(topic, clock.GetUtcNow(), "", ["payload-too-large:body-not-retained"]));
while (_diagnostics.Count > 32) _diagnostics.Dequeue();
}
}
public Ib43Report Receive(string topic, string payload, bool retained = false)
{
lock (_gate)
{
var now = clock.GetUtcNow();
var binding = options.Value.Devices.FirstOrDefault(d => d.PostTopic == topic);
Ib43Report report;
if (binding == null || retained || System.Text.Encoding.UTF8.GetByteCount(payload) > MaxPayloadBytes)
{
report = new() { Raw = payload.Length > 4096 ? payload[..4096] : payload, ReceivedAt = now };
report.Issues.Add(binding == null ? "unbound-topic" : retained ? "retained-not-live" : "payload-too-large");
}
else report = parser.Parse(topic, payload, binding, now);
_diagnostics.Enqueue(new(topic, now, report.Raw, report.Issues.ToArray()));
while (_diagnostics.Count > 32) _diagnostics.Dequeue();
if (!report.Accepted) return report;
if (!_states.TryGetValue(topic, out var state)) _states[topic] = state = new();
foreach (var (key, value) in report.Readings) state.Readings[key] = value;
foreach (var (key, value) in report.Fields) state.Fields[key] = new(value, now);
// 只有有效实时字段刷新活跃性;能力包、未知包和坏包不能掩盖实时数据停止。
if (report.Readings.Count > 0 || report.Fields.Keys.Any(k => k is "airbagsPerson" or
"airbagsPressure" or "motorHeight" or "motorStatus" or "snoreStatus"))
state.LastTelemetryAt = now;
return report;
}
}
public Ib43Snapshot[] Snapshot()
{
lock (_gate)
{
var now = clock.GetUtcNow();
return options.Value.Devices.Select(binding =>
{
_states.TryGetValue(binding.PostTopic, out var state);
var last = state?.LastTelemetryAt;
var freshness = last == null ? "unknown" :
now - last.Value > TimeSpan.FromSeconds(options.Value.StaleAfterSeconds) ? "stale" : "recent";
return new Ib43Snapshot(binding.ProductKey, binding.SerialNumber, binding.Simulation,
_transport, freshness, last,
state == null ? new Dictionary<string, Ib43Reading>() : new(state.Readings),
state == null ? new Dictionary<string, Ib43Field>() : new(state.Fields));
}).ToArray();
}
}
// 不通过公共 H5 API 暴露原始健康数据;后续持久化需绑定用户权限与保留期限。
public Ib43Diagnostic[] Diagnostics() { lock (_gate) return _diagnostics.ToArray(); }
}
@@ -0,0 +1,21 @@
namespace Evergreen.Health.Application.Health.Integrations.MiAirX;
/// <summary>播放能力契约,未来音箱接入不要求是 MiAirX。</summary>
public interface IAudioPlaybackGateway
{
bool IsConfigured { get; }
Task PlayAsync(CancellationToken cancellationToken = default);
Task PlayUrlAsync(string musicUrl, CancellationToken cancellationToken = default);
Task StopAsync(CancellationToken cancellationToken = default);
Task SetVolumeAsync(int volume, CancellationToken cancellationToken = default);
}
public interface ITcmMusicRuntime
{
TcmMusicTransitionOutput GetStatus();
bool HasPlayableTracks(string constitutionCode);
Task StartForConstitutionAsync(string constitutionCode, CancellationToken cancellationToken);
Task PlayTestMusicAsync(CancellationToken cancellationToken);
Task SetManualVolumeAsync(int volume, CancellationToken cancellationToken);
Task StopMusicAsync(CancellationToken cancellationToken);
}
@@ -0,0 +1,145 @@
using Microsoft.Extensions.Options;
using System.Net;
using System.Net.Http.Headers;
using System.Net.Http.Json;
using System.Text.Json;
namespace Evergreen.Health.Application.Health.Integrations.MiAirX;
public sealed class MiAirXClient : IAudioPlaybackGateway
{
private readonly HttpClient _httpClient;
private readonly MiAirXOptions _options;
private readonly SemaphoreSlim _loginLock = new(1, 1);
private string? _sessionToken;
public MiAirXClient(HttpClient httpClient, IOptions<MiAirXOptions> options)
{
_httpClient = httpClient;
_options = options.Value;
_httpClient.Timeout = TimeSpan.FromSeconds(Math.Clamp(_options.TimeoutSeconds, 3, 60));
}
public bool IsConfigured => _options.Enabled &&
!string.IsNullOrWhiteSpace(_options.BaseUrl) &&
!string.IsNullOrWhiteSpace(_options.DeviceId) &&
(!_options.RequireWebPassword || !string.IsNullOrWhiteSpace(_options.WebPassword));
public Task PlayAsync(CancellationToken cancellationToken = default)
=> PlayUrlAsync(_options.MusicUrl, cancellationToken);
public Task PlayUrlAsync(string musicUrl, CancellationToken cancellationToken = default)
{
if (!Uri.TryCreate(musicUrl, UriKind.Absolute, out var url) ||
url.Scheme is not ("http" or "https"))
throw new InvalidOperationException("MiAirX 音频 URL 未配置或格式无效");
return SendCommandAsync("api/play", new { did = _options.DeviceId, url = musicUrl }, cancellationToken);
}
public Task StopAsync(CancellationToken cancellationToken = default)
=> SendCommandAsync("api/stop", new { did = _options.DeviceId }, cancellationToken);
public Task SetVolumeAsync(int volume, CancellationToken cancellationToken = default)
{
if (volume is < 0 or > 100)
throw new InvalidOperationException("音箱音量应在 0 到 100 之间");
return SendCommandAsync("api/volume", new { did = _options.DeviceId, volume }, cancellationToken);
}
private async Task SendCommandAsync(string path, object payload, CancellationToken cancellationToken)
{
try { await SendCommandCoreAsync(path, payload, cancellationToken); }
catch (OperationCanceledException exception) when (!cancellationToken.IsCancellationRequested)
{
throw new TimeoutException("MiAirX 请求超时,未确认音箱操作完成", exception);
}
}
private async Task SendCommandCoreAsync(string path, object payload, CancellationToken cancellationToken)
{
var baseUri = GetBaseUri();
if (!string.IsNullOrWhiteSpace(_options.WebPassword))
await EnsureLoginAsync(baseUri, cancellationToken);
using var response = await SendOnceAsync(baseUri, path, payload, cancellationToken);
if (response.StatusCode == HttpStatusCode.Unauthorized && !string.IsNullOrWhiteSpace(_options.WebPassword))
{
_sessionToken = null;
await EnsureLoginAsync(baseUri, cancellationToken);
using var retry = await SendOnceAsync(baseUri, path, payload, cancellationToken);
await EnsureCommandSucceededAsync(retry, cancellationToken);
return;
}
if (response.StatusCode == HttpStatusCode.Unauthorized)
throw new InvalidOperationException("MiAirX 要求登录,请在后端配置 MiAirX 管理密码");
await EnsureCommandSucceededAsync(response, cancellationToken);
}
private async Task<HttpResponseMessage> SendOnceAsync(Uri baseUri, string path, object payload, CancellationToken cancellationToken)
{
using var request = new HttpRequestMessage(HttpMethod.Post, new Uri(baseUri, path))
{
Content = JsonContent.Create(payload)
};
if (!string.IsNullOrWhiteSpace(_sessionToken))
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", _sessionToken);
return await _httpClient.SendAsync(request, cancellationToken);
}
private async Task EnsureLoginAsync(Uri baseUri, CancellationToken cancellationToken)
{
if (!string.IsNullOrWhiteSpace(_sessionToken)) return;
await _loginLock.WaitAsync(cancellationToken);
try
{
if (!string.IsNullOrWhiteSpace(_sessionToken)) return;
using var response = await _httpClient.PostAsJsonAsync(new Uri(baseUri, "api/auth/login"),
new { password = _options.WebPassword }, cancellationToken);
if (!response.IsSuccessStatusCode)
throw new InvalidOperationException("MiAirX 登录失败,请检查管理密码");
if (!response.Headers.TryGetValues("Set-Cookie", out var cookies))
throw new InvalidOperationException("MiAirX 登录成功但没有返回会话凭据");
var tokenCookie = cookies.Select(cookie => cookie.Split(';', 2)[0])
.FirstOrDefault(cookie => cookie.StartsWith("miairx_token=", StringComparison.Ordinal));
_sessionToken = tokenCookie?["miairx_token=".Length..];
if (string.IsNullOrWhiteSpace(_sessionToken))
throw new InvalidOperationException("MiAirX 登录成功但会话凭据无效");
}
finally
{
_loginLock.Release();
}
}
private Uri GetBaseUri()
{
if (!_options.Enabled) throw new InvalidOperationException("MiAirX 接入未启用");
if (string.IsNullOrWhiteSpace(_options.DeviceId)) throw new InvalidOperationException("MiAirX 音箱 DID 未配置");
if (_options.RequireWebPassword && string.IsNullOrWhiteSpace(_options.WebPassword))
throw new InvalidOperationException("MiAirX 管理密码未配置");
if (!Uri.TryCreate(_options.BaseUrl, UriKind.Absolute, out var baseUri) ||
baseUri.Scheme is not ("http" or "https"))
throw new InvalidOperationException("MiAirX 服务地址未配置或格式无效");
return new Uri(baseUri.ToString().TrimEnd('/') + "/");
}
private static async Task EnsureCommandSucceededAsync(HttpResponseMessage response, CancellationToken cancellationToken)
{
if (response.StatusCode == HttpStatusCode.Unauthorized)
throw new InvalidOperationException("MiAirX 登录已失效,请检查管理密码");
if (!response.IsSuccessStatusCode)
throw new HttpRequestException($"MiAirX 返回 HTTP {(int)response.StatusCode}");
using var document = await JsonDocument.ParseAsync(await response.Content.ReadAsStreamAsync(cancellationToken),
cancellationToken: cancellationToken);
if (!document.RootElement.TryGetProperty("success", out var success) || success.ValueKind != JsonValueKind.True)
{
var error = document.RootElement.TryGetProperty("error", out var errorValue)
? errorValue.GetString() : null;
var detail = string.IsNullOrWhiteSpace(error) ? "" : $":{error[..Math.Min(error.Length, 120)]}";
throw new InvalidOperationException($"MiAirX 未能完成指令{detail}");
}
}
}
@@ -0,0 +1,14 @@
using Furion.ConfigurableOptions;
namespace Evergreen.Health.Application.Health.Integrations.MiAirX;
public sealed class MiAirXOptions : IConfigurableOptions
{
public bool Enabled { get; set; } = true;
public string BaseUrl { get; set; } = string.Empty;
public string DeviceId { get; set; } = string.Empty;
public string MusicUrl { get; set; } = string.Empty;
public bool RequireWebPassword { get; set; } = true;
public string WebPassword { get; set; } = string.Empty;
public int TimeoutSeconds { get; set; } = 15;
}
@@ -0,0 +1,18 @@
using Furion.ConfigurableOptions;
namespace Evergreen.Health.Application.Health.Integrations.MiAirX;
public sealed class TcmMusicOptions : IConfigurableOptions
{
public int StartVolume { get; set; } = 50;
public int FadeStepSeconds { get; set; } = 5;
public Dictionary<string, List<TcmMusicTrackOptions>> Tracks { get; set; } = new();
}
public sealed class TcmMusicTrackOptions
{
public string Title { get; set; } = string.Empty;
public string FileName { get; set; } = string.Empty;
public string Url { get; set; } = string.Empty;
public double DurationSeconds { get; set; }
}
@@ -0,0 +1,216 @@
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using System.Diagnostics;
namespace Evergreen.Health.Application.Health.Integrations.MiAirX;
/// <summary>Demo 音乐任务由服务端持续驱动,页面关闭后仍按体质曲目循环渐弱播放。</summary>
public sealed class TcmMusicTransitionService : IHostedService, ITcmMusicRuntime
{
private const int DemoStartVolume = 50;
private const int DemoFadeStepSeconds = 5;
private readonly IAudioPlaybackGateway _miAirX;
private readonly TcmMusicOptions _options;
private readonly ILogger<TcmMusicTransitionService> _logger;
private readonly TimeProvider _time;
private readonly SemaphoreSlim _operationLock = new(1, 1);
private readonly object _statusLock = new();
private TcmMusicTransitionOutput _status = new();
private CancellationTokenSource? _activeCancellation;
private Task? _activeTask;
public TcmMusicTransitionService(
IAudioPlaybackGateway audioGateway,
IOptions<TcmMusicOptions> musicOptions,
ILogger<TcmMusicTransitionService> logger,
TimeProvider time)
{
_miAirX = audioGateway;
_options = musicOptions.Value;
_logger = logger;
_time = time;
}
public Task StartAsync(CancellationToken cancellationToken) => Task.CompletedTask;
public TcmMusicTransitionOutput GetStatus()
{
lock (_statusLock)
return new TcmMusicTransitionOutput
{
Running = _status.Running,
HasVolume = _status.HasVolume,
VolumePercent = _status.VolumePercent,
Message = _status.Message
};
}
public async Task StopAsync(CancellationToken cancellationToken)
{
try { await StopMusicAsync(cancellationToken); }
catch (Exception exception) when (exception is not OperationCanceledException)
{
_logger.LogWarning(exception, "应用退出时未能停止助眠音乐");
}
}
public bool HasPlayableTracks(string constitutionCode)
=> _miAirX.IsConfigured &&
_options.Tracks is not null && _options.Tracks.TryGetValue(constitutionCode, out var tracks) &&
tracks is not null && tracks.Count > 0 &&
tracks.All(track => IsValidTrack(track));
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 尚未配置完整");
var tracks = _options.Tracks[constitutionCode].ToArray();
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(); }
}
public async Task PlayTestMusicAsync(CancellationToken cancellationToken)
{
await _operationLock.WaitAsync(cancellationToken);
try
{
await CancelCurrentLoopAsync();
await _miAirX.PlayAsync(cancellationToken);
UpdateStatus(false, null, "正在播放测试音乐");
}
finally { _operationLock.Release(); }
}
public async Task SetManualVolumeAsync(int volume, CancellationToken cancellationToken)
{
await _operationLock.WaitAsync(cancellationToken);
try
{
// 手动调音量接管当前播放,避免下一次自动渐弱立刻覆盖用户选择。
await CancelCurrentLoopAsync();
await _miAirX.SetVolumeAsync(volume, cancellationToken);
UpdateStatus(false, volume, "音量已手动设置");
}
finally { _operationLock.Release(); }
}
public async Task StopMusicAsync(CancellationToken cancellationToken)
{
await _operationLock.WaitAsync(cancellationToken);
try
{
await CancelCurrentLoopAsync();
await _miAirX.StopAsync(cancellationToken);
UpdateStatus(false, null, "音乐已停止");
}
finally { _operationLock.Release(); }
}
private async Task RunFadeAsync(
IReadOnlyList<TcmMusicTrackOptions> tracks,
string constitutionCode,
CancellationToken cancellationToken)
{
var start = _time.GetTimestamp();
var currentVolume = DemoStartVolume;
var nextFadeSecond = (double)DemoFadeStepSeconds;
var trackStartSecond = 0d;
var trackIndex = 0;
try
{
while (true)
{
await Task.Delay(TimeSpan.FromSeconds(1), _time, cancellationToken);
var elapsed = _time.GetElapsedTime(start).TotalSeconds;
while (elapsed >= nextFadeSecond && currentVolume > 0)
{
currentVolume--;
await _miAirX.SetVolumeAsync(currentVolume, cancellationToken);
UpdateStatus(true, currentVolume, "体质音乐正在自动渐弱");
nextFadeSecond += DemoFadeStepSeconds;
}
if (currentVolume == 0)
{
await _miAirX.StopAsync(cancellationToken);
UpdateStatus(false, 0, "音量已降至 0,音乐已停止");
_logger.LogInformation("体质 {ConstitutionCode} 的助眠音乐已渐弱至 0 并停止", constitutionCode);
return;
}
if (elapsed - trackStartSecond < tracks[trackIndex].DurationSeconds) continue;
trackIndex = (trackIndex + 1) % tracks.Count;
await _miAirX.PlayUrlAsync(tracks[trackIndex].Url, cancellationToken);
trackStartSecond = _time.GetElapsedTime(start).TotalSeconds;
_logger.LogInformation("体质 {ConstitutionCode} 循环播放 {Track},继承音量 {Volume}",
constitutionCode, tracks[trackIndex].Title, currentVolume);
}
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) { }
catch (Exception exception)
{
_logger.LogWarning(exception, "体质音乐渐弱或续播中断");
try { await _miAirX.StopAsync(CancellationToken.None); }
catch (Exception stopException) { _logger.LogWarning(stopException, "体质音乐中断后停止音箱失败"); }
UpdateStatus(false, null, "自动渐弱中断,请查看后端日志");
}
}
private async Task CancelCurrentLoopAsync()
{
var cancellation = _activeCancellation;
var task = _activeTask;
_activeCancellation = null;
_activeTask = null;
if (cancellation is null) return;
cancellation.Cancel();
try { if (task is not null) await task; }
finally { cancellation.Dispose(); }
UpdateStatus(false, null, "自动渐弱已停止");
}
private void UpdateStatus(bool running, int? volume, string message)
{
lock (_statusLock)
{
_status.Running = running;
if (volume.HasValue)
{
_status.HasVolume = true;
_status.VolumePercent = volume.Value;
}
_status.Message = message;
}
}
private static bool IsValidTrack(TcmMusicTrackOptions track)
=> track is not null && double.IsFinite(track.DurationSeconds) && track.DurationSeconds > 0 &&
Uri.TryCreate(track.Url, UriKind.Absolute, out var uri) &&
uri.Scheme is "http" or "https";
}
public sealed class TcmMusicTransitionOutput
{
public bool Running { get; set; }
public bool HasVolume { get; set; }
public int VolumePercent { get; set; }
public string Message { get; set; } = string.Empty;
}
@@ -0,0 +1,19 @@
namespace Evergreen.Health.Application.Health.Integrations.Tuya;
/// <summary>未来独立涂鸦模块使用本接口,不要求经过 Home Assistant。</summary>
public sealed record TuyaCapabilityWrite(string DeviceId, string Code, object? Value);
public interface ITuyaGateway
{
bool IsConfigured { get; }
Task WriteAsync(TuyaCapabilityWrite command, CancellationToken cancellationToken);
}
public sealed class ReservedTuyaGateway : ITuyaGateway
{
public bool IsConfigured => false;
public Task WriteAsync(TuyaCapabilityWrite command, CancellationToken cancellationToken)
{
cancellationToken.ThrowIfCancellationRequested();
throw new NotSupportedException("涂鸦接入仅预留,本版没有发送控制命令");
}
}
@@ -94,4 +94,104 @@ public sealed class HealthDashboardOutput
public SaveHealthProfileInput? Profile { get; set; } public SaveHealthProfileInput? Profile { get; set; }
public List<string> SelectedModules { get; set; } = []; public List<string> SelectedModules { get; set; } = [];
public HealthPlanOutput? CurrentPlan { get; set; } public HealthPlanOutput? CurrentPlan { get; set; }
public bool HasCompletedAssessment { get; set; }
public decimal? Bmi { get; set; }
public string BmiStatus { get; set; } = string.Empty;
public PersonalizedSceneConfig? SceneConfig { get; set; }
public TcmAssessmentResult? TcmResult { get; set; }
public EnvironmentAdviceOutput? EnvironmentAdvice { get; set; }
}
public sealed class EnvironmentAdviceOutput
{
public double? IndoorTemperatureCelsius { get; set; }
public bool IndoorTemperatureAvailable { get; set; }
public DateTimeOffset? IndoorTemperatureObservedAt { get; set; }
public ClimateAdviceOutput ClimateAdvice { get; set; } = new();
}
public sealed class TcmAssessmentInput
{
public Dictionary<string, string> Answers { get; set; } = new();
public Dictionary<string, string> Notes { get; set; } = new();
}
public sealed class TcmAssessmentResult
{
public bool HasCompleted { get; set; }
public string ConstitutionCode { get; set; } = string.Empty;
public string ConstitutionName { get; set; } = string.Empty;
public string? SecondaryName { get; set; }
public string Summary { get; set; } = string.Empty;
public string AromaRecommendation { get; set; } = string.Empty;
public string MusicRecommendation { get; set; } = string.Empty;
public string RoomTemperatureRecommendation { get; set; } = string.Empty;
public Dictionary<string, int> Scores { get; set; } = new();
public Dictionary<string, string> Answers { get; set; } = new();
public Dictionary<string, string> Notes { get; set; } = new();
public PersonalizedSceneConfig SceneConfig { get; set; } = new();
public DateTime? AssessedAt { get; set; }
}
public sealed class PersonalizedSceneConfig
{
public int TargetHumidity { get; set; } = 55;
public string DehumidifierMode { get; set; } = "睡眠";
public string FanLevel { get; set; } = "1档";
public bool EnableUv { get; set; } = false;
public int LightBrightnessPercent { get; set; } = 20;
public int LightColorTemperatureKelvin { get; set; } = 2700;
public bool EnableFragrance { get; set; } = true;
public int FragranceLevel { get; set; } = 1;
public bool EnableSpeakerSleepMode { get; set; } = true;
public string SpeakerAudioMode { get; set; } = "助眠白噪音";
public int SpeakerVolumePercent { get; set; } = 50;
public ClimateAdviceOutput ClimateAdvice { get; set; } = new();
}
public sealed class ClimateAdviceOutput
{
public string Mode { get; set; } = "unavailable";
public int RecommendedMinimumCelsius { get; set; }
public int RecommendedMaximumCelsius { get; set; }
public int? TargetTemperatureCelsius { get; set; }
public bool PendingIntegration { get; set; }
public string Reason { get; set; } = "等待有效室温";
}
public sealed class HealthAssessmentAnswers
{
public string SleepDuration { get; set; } = string.Empty;
public string FallingAsleepSpeed { get; set; } = string.Empty;
public string TemperaturePreference { get; set; } = string.Empty;
public string NightAwakening { get; set; } = string.Empty;
public string MattressPreference { get; set; } = string.Empty;
public string ExerciseFrequency { get; set; } = string.Empty;
public string MuscleFatigueArea { get; set; } = string.Empty;
public string ActiveTimeOfDay { get; set; } = string.Empty;
public string MealRegularity { get; set; } = string.Empty;
public string FlavorPreference { get; set; } = string.Empty;
public string LateNightEating { get; set; } = string.Empty;
public string DailyWaterIntake { get; set; } = string.Empty;
}
public sealed class SaveHealthAssessmentInput
{
public SaveHealthProfileInput BasicInfo { get; set; } = new();
public HealthAssessmentAnswers Answers { get; set; } = new();
}
public sealed class HealthAssessmentOutput
{
public bool HasCompletedAssessment { get; set; }
public SaveHealthProfileInput BasicInfo { get; set; } = new();
public HealthAssessmentAnswers Answers { get; set; } = new();
public decimal? Bmi { get; set; }
public string BmiStatus { get; set; } = "正常";
} }
@@ -9,7 +9,11 @@ public enum HealthDeviceCategory
Fragrance, Fragrance,
Lighting, Lighting,
Media, Media,
Other Other,
Climate,
Curtain,
SleepMonitoring,
Outlet
} }
public sealed class HomeAssistantEntityOutput public sealed class HomeAssistantEntityOutput
@@ -25,6 +29,7 @@ public sealed class HomeAssistantEntityOutput
public sealed class HomeAssistantDiscoveredDeviceOutput public sealed class HomeAssistantDiscoveredDeviceOutput
{ {
public string ModuleKey { get; set; } = string.Empty;
public string DeviceId { get; set; } = string.Empty; public string DeviceId { get; set; } = string.Empty;
public string Name { get; set; } = string.Empty; public string Name { get; set; } = string.Empty;
public string Manufacturer { get; set; } = string.Empty; public string Manufacturer { get; set; } = string.Empty;
@@ -32,6 +37,8 @@ public sealed class HomeAssistantDiscoveredDeviceOutput
public string? AreaName { get; set; } public string? AreaName { get; set; }
public string Category { get; set; } = nameof(HealthDeviceCategory.Other); public string Category { get; set; } = nameof(HealthDeviceCategory.Other);
public bool Online { get; set; } public bool Online { get; set; }
public string? PrimaryControlEntityId { get; set; }
public string? DefaultLightEffect { get; set; }
public List<HomeAssistantEntityOutput> Entities { get; set; } = []; public List<HomeAssistantEntityOutput> Entities { get; set; } = [];
public Dictionary<string, object?> Metrics { get; set; } = []; public Dictionary<string, object?> Metrics { get; set; } = [];
} }
@@ -71,10 +78,78 @@ public sealed class HomeAssistantStatusOutput
public List<HomeAssistantDiscoveredDeviceOutput> Devices { get; set; } = []; public List<HomeAssistantDiscoveredDeviceOutput> Devices { get; set; } = [];
public HomeAssistantSceneDevicesSummaryOutput SceneDevices { get; set; } = new(); public HomeAssistantSceneDevicesSummaryOutput SceneDevices { get; set; } = new();
public HomeAssistantEnvironmentOutput Environment { get; set; } = new(); public HomeAssistantEnvironmentOutput Environment { get; set; } = new();
public List<HomeAssistantScenePresetItem> ScenePresets { get; set; } = [];
public Dictionary<string, HomeAssistantParameterStateOutput> ControlParameters { get; set; } = [];
}
public sealed class HomeAssistantParameterStateOutput
{
public string EntityId { get; set; } = string.Empty;
public string State { get; set; } = string.Empty;
public bool Available { get; set; }
public Dictionary<string, object?>? Attributes { get; set; }
}
public sealed class HomeAssistantScenePresetItem
{
public string DeviceName { get; set; } = string.Empty;
public string Category { get; set; } = string.Empty;
public string EntityId { get; set; } = string.Empty;
public bool Available { get; set; }
public string AvailabilityMessage { get; set; } = string.Empty;
public string Action { get; set; } = string.Empty;
public string Detail { get; set; } = string.Empty;
public Dictionary<string, object?> Parameters { get; set; } = [];
}
public sealed class HomeAssistantDeviceSwitchInput
{
public string DeviceId { get; set; } = string.Empty;
public string? EntityId { get; set; }
public bool TurnOn { get; set; }
}
public sealed class HomeAssistantDeviceSwitchOutput
{
public bool Success { get; set; }
public string DeviceId { get; set; } = string.Empty;
public string EntityId { get; set; } = string.Empty;
public string State { get; set; } = string.Empty;
public bool? IsMuted { get; set; }
public string Message { get; set; } = string.Empty;
}
public sealed class HomeAssistantLightTransitionOutput
{
public string EntityId { get; set; } = string.Empty;
public bool Success { get; set; } = true;
public bool Running { get; set; }
public string Mode { get; set; } = "none";
public int BrightnessPercent { get; set; }
public int TargetPercent { get; set; }
public string Message { get; set; } = string.Empty;
public List<HomeAssistantLightTransitionOutput> Lights { get; set; } = [];
}
public sealed class HomeAssistantSetParameterInput
{
public string DeviceId { get; set; } = string.Empty;
public string? EntityId { get; set; }
public string ParameterType { get; set; } = string.Empty;
public object? Value { get; set; }
}
public sealed class HomeAssistantSetParameterOutput
{
public bool Success { get; set; }
public string DeviceId { get; set; } = string.Empty;
public string ParameterType { get; set; } = string.Empty;
public string Message { get; set; } = string.Empty;
} }
public sealed class HomeAssistantSceneOutput public sealed class HomeAssistantSceneOutput
{ {
public ClimateAdviceOutput? ClimateAdvice { get; set; }
public string SceneCode { get; set; } = string.Empty; public string SceneCode { get; set; } = string.Empty;
public string SceneName { get; set; } = string.Empty; public string SceneName { get; set; } = string.Empty;
public bool Success { get; set; } public bool Success { get; set; }
@@ -0,0 +1,50 @@
using Evergreen.Health.Application.Health.Models;
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), _ => Default
};
}
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
{
public static ClimateAdviceOutput Evaluate(IndoorTemperatureSample sample, RecommendedRoomRange range)
{
var mode = !sample.Available || sample.Celsius is null ? "unavailable"
: sample.Celsius < range.Minimum ? "heat" : sample.Celsius > range.Maximum ? "cool" : "off";
return new()
{
Mode = mode, RecommendedMinimumCelsius = range.Minimum, RecommendedMaximumCelsius = range.Maximum,
Reason = mode switch
{
"heat" => $"室温低于建议 {range.Display}:建议空调制热",
"cool" => $"室温高于建议 {range.Display}:建议空调制冷",
"off" => $"室温在建议 {range.Display} 内:空调保持当前运行状态",
_ => $"{sample.Reason},不自动开启空调"
},
PendingIntegration = mode is "heat" or "cool"
};
}
// 仅在启动新舒睡场景时调用,状态查询和预览不随机更改目标值。
public static void PickTargetForSession(ClimateAdviceOutput advice)
{
if (advice.TargetTemperatureCelsius is null && (advice.Mode is "heat" or "cool"))
advice.TargetTemperatureCelsius = Random.Shared.Next(advice.RecommendedMinimumCelsius,
advice.RecommendedMaximumCelsius + 1);
}
}
@@ -0,0 +1,183 @@
using Evergreen.Health.Application.Health.Devices.Contracts;
using Evergreen.Health.Application.Health.Integrations.HomeAssistant;
using System.Globalization;
namespace Evergreen.Health.Application.Health.Scenes;
/// <summary>共用发送、取消、回读和有限重试,任何家具都可复用。</summary>
public sealed class DeviceActionRunner
{
private readonly IHomeAssistantGateway _gateway;
private readonly TimeProvider _time;
private readonly DeviceExecutionOptions _options;
private readonly IReadOnlyList<IDeviceActionSafetyRule> _safety;
private readonly SemaphoreSlim _commandGate = new(1, 1);
public DeviceActionRunner(IHomeAssistantGateway gateway, TimeProvider time, DeviceExecutionOptions options,
IEnumerable<IDeviceActionSafetyRule>? safety = null)
=> (_gateway, _time, _options, _safety) = (gateway, time, options, safety?.ToArray() ?? []);
public static bool IsAvailable(HomeAssistantState state)
=> !string.IsNullOrWhiteSpace(state.State) &&
!string.Equals(state.State, "unavailable", StringComparison.OrdinalIgnoreCase) &&
(!string.Equals(state.State, "unknown", StringComparison.OrdinalIgnoreCase) ||
state.EntityId.StartsWith("button.", StringComparison.OrdinalIgnoreCase));
public async Task<HomeAssistantState> ReadAvailableStateAsync(string entityId, CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(entityId)) throw new InvalidOperationException("参数实体尚未配置");
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 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))
throw new InvalidOperationException("参数实体尚未配置");
await _commandGate.WaitAsync(cancellationToken);
try
{
using var timeout = new CancellationTokenSource(_options.CommandTimeout, _time);
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(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)
{
throw new TimeoutException($"{command.Name}发送超时,未确认完成");
}
foreach (var rule in _safety) rule.Accepted(command, _time.GetUtcNow());
return null;
}
finally { _commandGate.Release(); }
}
public Task DelayAsync(TimeSpan delay, CancellationToken cancellationToken)
=> Task.Delay(delay, _time, cancellationToken);
public async Task RunAsync(IReadOnlyDictionary<string, HomeAssistantState> states,
List<HomeAssistantActionResult> results, DeviceCommand command, CancellationToken cancellationToken,
bool requireAvailable = true)
{
cancellationToken.ThrowIfCancellationRequested();
var action = new HomeAssistantActionResult { Name = command.Name, EntityId = command.EntityId };
results.Add(action);
if (string.IsNullOrWhiteSpace(command.EntityId))
{
action.Status = "Skipped"; action.Message = "实体未配置"; return;
}
if (requireAvailable && (!states.TryGetValue(command.EntityId, out var state) || !IsAvailable(state)))
{
action.Status = "Skipped"; action.Message = "实体不存在或当前离线"; return;
}
try
{
await SendAsync(command, cancellationToken);
action.Status = "Succeeded"; action.Message = "控制指令已发送";
}
catch (Exception exception) when (exception is not OperationCanceledException)
{
action.Status = "Failed"; action.Message = exception.Message;
}
}
public async Task VerifyParameterAsync(DeviceCommand command, CancellationToken cancellationToken, int? verificationAttempts = null)
{
var results = new List<HomeAssistantActionResult>
{
new() { Name = command.Name, EntityId = command.EntityId, Status = "Succeeded" }
};
await VerifyBatchAsync([command], results, cancellationToken, verificationAttempts);
if (results[0].Status != "Succeeded") throw new InvalidOperationException(results[0].Message);
}
public async Task VerifyBatchAsync(IReadOnlyList<DeviceCommand> commands,
List<HomeAssistantActionResult> results, CancellationToken cancellationToken, int? verificationAttempts = null)
{
var attempts = verificationAttempts ?? _options.VerificationAttempts;
var pending = commands.Where(command => !string.IsNullOrWhiteSpace(command.EntityId) &&
command.ExpectedState is not null).ToList();
for (var round = 0; round < attempts && pending.Count > 0; round++)
{
await DelayAsync(_options.VerificationDelay, cancellationToken);
IReadOnlyList<HomeAssistantState> states;
try { states = await _gateway.GetStatesAsync(cancellationToken); }
catch (Exception exception) when (exception is not OperationCanceledException)
{
foreach (var command in pending)
{
var action = FindResult(results, command);
action.Status = "Failed"; action.Message = $"无法回读确认:{exception.Message}";
}
return;
}
foreach (var command in pending.ToArray())
{
var action = FindResult(results, command);
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!) &&
(command.ExpectedEntityState is null || Matches(state.State, command.ExpectedEntityState)))
{
action.Status = "Succeeded"; action.Message = round == 0 ? "已回读确认目标状态" : "重试后已回读确认目标状态";
pending.Remove(command); continue;
}
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;
// 关闭属于尽力而为:缓存离线不能阻止发出重试,发送失败也不影响其他家具。
try { await SendAsync(command, cancellationToken); }
catch (Exception exception) when (exception is not OperationCanceledException)
{
action.Status = "Failed"; action.Message = $"重试失败:{exception.Message}";
}
}
}
}
private static HomeAssistantActionResult FindResult(List<HomeAssistantActionResult> results, DeviceCommand command)
=> results.Last(item => item.Name == command.Name && item.EntityId == command.EntityId);
private static bool Matches(string actual, string expected)
=> string.Equals(actual, expected, StringComparison.OrdinalIgnoreCase) ||
(decimal.TryParse(actual, NumberStyles.Number, CultureInfo.InvariantCulture, out var actualNumber) &&
decimal.TryParse(expected, NumberStyles.Number, CultureInfo.InvariantCulture, out var expectedNumber) &&
actualNumber == expectedNumber);
}
public sealed class DeviceExecutionOptions
{
public TimeSpan CommandTimeout { get; init; } = TimeSpan.FromSeconds(10);
public int VerificationAttempts { get; init; } = 3;
public TimeSpan VerificationDelay { get; init; } = TimeSpan.FromSeconds(2);
}
@@ -0,0 +1,31 @@
using Evergreen.Health.Application.Health.Devices.Contracts;
using Evergreen.Health.Application.Health.Integrations.HomeAssistant;
using Microsoft.Extensions.Options;
namespace Evergreen.Health.Application.Health.Scenes;
public interface IDeviceActionSafetyRule
{
Task ValidateAsync(DeviceCommand command, CancellationToken cancellationToken);
void Accepted(DeviceCommand command, DateTimeOffset now);
}
public sealed class SceneLightSafetyState
{
private readonly object _lock = new();
private DateTimeOffset? _offAt;
public DateTimeOffset? OffAt { get { lock (_lock) return _offAt; } }
public void MarkOff(DateTimeOffset now) { lock (_lock) _offAt = now; }
public void MarkOn() { lock (_lock) _offAt = null; }
}
/// <summary>记录主灯的已发送开关状态,供晨起渐亮处理滞后的云端回读。</summary>
public sealed class PrimaryLightStateRule(SceneLightSafetyState state) : IDeviceActionSafetyRule
{
public Task ValidateAsync(DeviceCommand command, CancellationToken cancellationToken) => Task.CompletedTask;
public void Accepted(DeviceCommand command, DateTimeOffset now)
{
if (command.Intent == DeviceCommandIntent.TurnRelaxationLightOff) state.MarkOff(now);
else if (command.Intent == DeviceCommandIntent.TurnRelaxationLightOn) state.MarkOn();
}
}
@@ -0,0 +1,34 @@
using Evergreen.Health.Application.Health.Devices.Contracts;
using Evergreen.Health.Application.Health.Integrations.HomeAssistant;
namespace Evergreen.Health.Application.Health.Scenes;
public sealed class DeviceSceneContext
{
public IReadOnlyDictionary<string, HomeAssistantState> States { get; }
public List<HomeAssistantActionResult> Results { get; }
public DeviceActionRunner Runner { get; }
public DeviceSceneContext(DeviceActionRunner runner, IReadOnlyDictionary<string, HomeAssistantState> states,
List<HomeAssistantActionResult> results) => (Runner, States, Results) = (runner, states, results);
public Task RunAsync(DeviceCommand command, CancellationToken cancellationToken)
=> Runner.RunAsync(States, Results, command, cancellationToken);
public async Task ExternalAsync(string name, string entityId, Func<CancellationToken, Task> operation,
CancellationToken cancellationToken)
{
cancellationToken.ThrowIfCancellationRequested();
var result = new HomeAssistantActionResult { Name = name, EntityId = entityId };
Results.Add(result);
try
{
await operation(cancellationToken);
result.Status = string.IsNullOrWhiteSpace(entityId) ? "Completed" : "Succeeded";
result.Message = string.IsNullOrWhiteSpace(entityId) ? "内部任务已处理" : "控制指令已发送";
}
catch (Exception exception) when (exception is not OperationCanceledException)
{
result.Status = "Failed"; result.Message = exception.Message;
}
}
}
@@ -0,0 +1,152 @@
using Evergreen.Health.Application.Health.Devices;
using Evergreen.Health.Application.Health.Integrations.HomeAssistant;
using Evergreen.Health.Application.Health.Integrations.MiAirX;
using Evergreen.Health.Application.Health.Models;
namespace Evergreen.Health.Application.Health.Scenes;
/// <summary>同一房间串行执行,后来的开启/关闭取消旧启动,避免延迟指令重新打开家具。</summary>
public sealed class HealthSceneCoordinator
{
private readonly DeviceModuleRegistry _modules;
private readonly DeviceActionRunner _runner;
private readonly IHomeAssistantGateway _gateway;
private readonly ILightTransitionRuntime _light;
private readonly ITcmMusicRuntime _music;
private readonly SemaphoreSlim _gate = new(1, 1);
private readonly object _stateLock = new();
private CancellationTokenSource? _active;
public HealthSceneCoordinator(DeviceModuleRegistry modules, DeviceActionRunner runner,
IHomeAssistantGateway gateway, ILightTransitionRuntime light, ITcmMusicRuntime music)
=> (_modules, _runner, _gateway, _light, _music) = (modules, runner, gateway, light, music);
public Task<HomeAssistantSceneOutput> ExecuteAsync(string sceneCode, PersonalizedSceneConfig? config,
string constitutionCode, CancellationToken cancellationToken)
=> ExecuteCoreAsync(sceneCode, config, constitutionCode, cancellationToken, null);
// 先登记本次操作,再读取档案/室温。否则较慢的旧准备请求可能在全关之后才开始开家具。
public Task<HomeAssistantSceneOutput> ExecutePreparedSleepAsync(
Func<CancellationToken, Task<PreparedSleepScene>> prepare, CancellationToken cancellationToken)
=> ExecuteCoreAsync("sleep", null, "", cancellationToken, prepare);
private async Task<HomeAssistantSceneOutput> ExecuteCoreAsync(string sceneCode, PersonalizedSceneConfig? config,
string constitutionCode, CancellationToken cancellationToken,
Func<CancellationToken, Task<PreparedSleepScene>>? prepare)
{
if (sceneCode is not ("sleep" or "all-off")) throw new InvalidOperationException("不支持的场景");
if (sceneCode == "sleep" && config is null && prepare is null) throw new InvalidOperationException("舒睡场景缺少体质参数");
CancellationTokenSource operation;
lock (_stateLock)
{
_active?.Cancel();
operation = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
_active = operation;
}
var results = new List<HomeAssistantActionResult>();
var entered = false;
var cancelled = false;
string? preparationFailureStatus = null;
try
{
await _gate.WaitAsync(operation.Token); entered = true;
if (prepare is not null)
{
var prepared = await prepare(operation.Token);
operation.Token.ThrowIfCancellationRequested();
config = prepared.Config; constitutionCode = prepared.ConstitutionCode;
}
var context = new DeviceSceneContext(_runner, new Dictionary<string, HomeAssistantState>(), results);
await context.ExternalAsync("停止旧灯光渐变任务", "",
async token => { await _light.CancelAsync(token); }, operation.Token);
await context.ExternalAsync("停止旧助眠音乐", _modules.Modules.FirstOrDefault(module => module.Key == "audio")?.PrimaryEntityId ?? "",
_music.StopMusicAsync, operation.Token);
IReadOnlyDictionary<string, HomeAssistantState> states;
try
{
states = (await _gateway.GetStatesAsync(operation.Token)).GroupBy(state => state.EntityId, StringComparer.OrdinalIgnoreCase)
.ToDictionary(group => group.Key, group => group.Last(), StringComparer.OrdinalIgnoreCase);
}
catch (Exception exception) when (exception is not OperationCanceledException)
{
// HA 不可达不能阻止 MiAirX 停止或尝试发送其他关闭指令。
results.Add(new() { Name = "读取设备状态", Status = "Failed", Message = exception.Message });
states = new Dictionary<string, HomeAssistantState>();
}
context = new DeviceSceneContext(_runner, states, results);
if (sceneCode == "sleep")
{
foreach (var module in _modules.Modules)
{
operation.Token.ThrowIfCancellationRequested();
try { await module.ExecuteSleepAsync(context, config!, constitutionCode, operation.Token); }
catch (Exception exception) when (exception is not OperationCanceledException)
{
results.Add(new() { Name = $"{module.DisplayName}舒睡设置", Status = "Failed", Message = exception.Message });
}
}
}
else
{
var shutdown = _modules.Modules.SelectMany(module => module.GetShutdownCommands()).ToArray();
foreach (var command in shutdown)
await _runner.RunAsync(states, results, command, operation.Token, requireAvailable: false);
await _runner.VerifyBatchAsync(shutdown, results, operation.Token);
}
}
catch (OperationCanceledException) when (operation.IsCancellationRequested)
{
cancelled = true;
if (entered)
{
var context = new DeviceSceneContext(_runner, new Dictionary<string, HomeAssistantState>(), results);
await context.ExternalAsync("取消灯光渐变", "", async token => { await _light.CancelAsync(token); }, CancellationToken.None);
await context.ExternalAsync("取消音乐渐弱", "", _music.StopMusicAsync, CancellationToken.None);
}
}
catch (Exception exception) when (exception is not OperationCanceledException)
{
preparationFailureStatus = exception is ScenePreparationException ? "AssessmentRequired" : "Failed";
results.Add(new() { Name = "准备或协调场景", Status = "Failed", Message = exception.Message });
}
finally
{
if (entered) _gate.Release();
lock (_stateLock)
{
if (ReferenceEquals(_active, operation)) _active = null;
operation.Dispose();
}
}
// 取消内部计时任务不能冒充家具执行成功。
var successCount = results.Count(result => result.Status == "Succeeded" && !string.IsNullOrWhiteSpace(result.EntityId));
var failed = results.Where(result => result.Status == "Failed").ToArray();
var skippedCount = results.Count(result => result.Status == "Skipped");
var status = preparationFailureStatus ?? (cancelled ? "Cancelled" : failed.Length == 0 && successCount > 0
? "Succeeded" : successCount > 0 ? "Partial" : "Failed");
var message = status switch
{
"Cancelled" => "当前场景已被更新的操作取消",
"AssessmentRequired" => failed.First().Message,
"Succeeded" => $"场景已执行:{successCount} 项成功,{skippedCount} 项未配置或离线而跳过",
"Partial" => $"场景部分完成:{failed.Length} 项失败,{skippedCount} 项跳过;未完成:{string.Join("、", failed.Select(result => result.Name).Distinct().Take(3))}",
_ => failed.Length == 0 ? "没有已执行的家具控制;设备未配置或当前不可用"
: $"场景未完成:{string.Join(";", failed.Select(result => result.Message).Take(3))}"
};
return new()
{
SceneCode = sceneCode, SceneName = sceneCode == "sleep" ? "舒睡模式" : "一键关闭",
Success = status is "Succeeded" or "Partial", Status = status, Message = message,
ClimateAdvice = sceneCode == "sleep" ? config?.ClimateAdvice : null,
Actions = results.Select(result => new HomeAssistantSceneActionOutput
{ Name = result.Name, EntityId = result.EntityId, Status = result.Status, Message = result.Message }).ToList()
};
}
}
public sealed record PreparedSleepScene(PersonalizedSceneConfig Config, string ConstitutionCode);
public sealed class ScenePreparationException : InvalidOperationException
{
public ScenePreparationException(string message) : base(message) { }
}
@@ -0,0 +1,25 @@
namespace Evergreen.Health.Application.Health.Scenes;
public interface ILightOffHandler
{
Task HandleAsync(CancellationToken cancellationToken);
}
/// <summary>跨设备联动由场景层分发;灯具不需要知道其他家具实体。</summary>
public sealed class LightOffDispatcher
{
private readonly IReadOnlyList<ILightOffHandler> _handlers;
public LightOffDispatcher(IEnumerable<ILightOffHandler> handlers) => _handlers = handlers.ToArray();
public async Task NotifyAsync(CancellationToken cancellationToken)
{
var failures = new List<Exception>();
foreach (var handler in _handlers)
{
cancellationToken.ThrowIfCancellationRequested();
try { await handler.HandleAsync(cancellationToken); }
catch (Exception exception) when (exception is not OperationCanceledException) { failures.Add(exception); }
}
if (failures.Count > 0)
throw new AggregateException("灯光已关闭,但部分安全联动未完成", failures);
}
}
@@ -0,0 +1,54 @@
using Evergreen.Health.Application.Health.Devices.Environment;
using Evergreen.Health.Application.Health.Entities;
using Evergreen.Health.Application.Health.Integrations.HomeAssistant;
using Evergreen.Health.Application.Health.Models;
using Evergreen.Health.Application.Health.Policies;
using Microsoft.Extensions.Options;
namespace Evergreen.Health.Application.Health.Services;
/// <summary>依据室温与体质提供空调建议,不负责发送家具控制命令。</summary>
public sealed class EnvironmentAdviceService : ITransient
{
private readonly IHomeAssistantGateway _gateway;
private readonly IndoorEnvironmentReader _environment;
private readonly HomeAssistantEntityOptions _entities;
public EnvironmentAdviceService(IHomeAssistantGateway gateway, IndoorEnvironmentReader environment,
IOptions<HomeAssistantOptions> options)
=> (_gateway, _environment, _entities) = (gateway, environment, options.Value.Entities);
public async Task<EnvironmentAdviceOutput> GetAsync(HealthProfile? profile, CancellationToken cancellationToken = default)
{
IReadOnlyDictionary<string, HomeAssistantState> states;
try
{
states = (await _gateway.GetStatesAsync(cancellationToken)).GroupBy(state => state.EntityId, StringComparer.OrdinalIgnoreCase)
.ToDictionary(group => group.Key, group => group.Last(), StringComparer.OrdinalIgnoreCase);
}
catch (Exception exception) when (exception is not OperationCanceledException)
{
states = new Dictionary<string, HomeAssistantState>();
}
return Get(profile, states);
}
public EnvironmentAdviceOutput Get(HealthProfile? profile, IReadOnlyDictionary<string, HomeAssistantState> states)
{
var tcm = TcmConstitutionRules.Read(profile);
var sample = _environment.Read(states);
var advice = RoomTemperaturePolicy.Evaluate(sample, RecommendedRoomRange.ForConstitution(tcm.ConstitutionCode));
advice.PendingIntegration = (advice.Mode is "heat" or "cool") && string.IsNullOrWhiteSpace(_entities.Climate);
return new()
{
IndoorTemperatureCelsius = sample.Celsius, IndoorTemperatureAvailable = sample.Available,
IndoorTemperatureObservedAt = sample.ObservedAt,
ClimateAdvice = advice
};
}
public static void ApplyTo(PersonalizedSceneConfig config, EnvironmentAdviceOutput recommendation)
{
config.ClimateAdvice = recommendation.ClimateAdvice;
}
}

Some files were not shown because too many files have changed in this diff Show More