1
This commit is contained in:
@@ -18,9 +18,14 @@
|
||||
<PackageReference Include="Selenium.WebDriver" Version="4.40.0" />
|
||||
<PackageReference Include="Selenium.WebDriver.MSEdgeDriver" Version="144.0.3719.82" />
|
||||
<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>
|
||||
<ProjectReference Include="..\Admin.NET.Core\Admin.NET.Core.csproj" />
|
||||
<ProjectReference Include="..\Evergreen.Health.Application\Evergreen.Health.Application.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
||||
@@ -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 MattressComfortService(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,408 @@
|
||||
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.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 static HomeAssistantOptions Settings() => new() { Entities = new() { Climate = ClimateId, Curtain = CurtainId } };
|
||||
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 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 = "测试室温建议" }
|
||||
};
|
||||
|
||||
[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 Task<IReadOnlyList<HomeAssistantState>> GetStatesAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested(); 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"]!, "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,310 @@
|
||||
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.Devices.Mattress;
|
||||
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", MattressLeftMode = "select.left_mode", MattressRightMode = "select.right_mode",
|
||||
MattressLeftHeatingLevel = "number.left_heat", MattressRightHeatingLevel = "number.right_heat",
|
||||
MattressLeftMassagePart = "select.left_massage", MattressRightMassagePart = "select.right_massage",
|
||||
MattressMassageLevel = "select.massage_level", MattressFirmness = new() { ["left_shoulder"] = "select.left_shoulder" }
|
||||
}, SleepScene = new() { EnableMattress = true }
|
||||
};
|
||||
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(18.4, "很软")]
|
||||
[InlineData(18.5, "稍软")]
|
||||
[InlineData(24.0, "稍硬")]
|
||||
[InlineData(28.0, "很硬")]
|
||||
[InlineData(31.1, "很硬")]
|
||||
[InlineData(32.0, "最硬")]
|
||||
public void BmiUsesStableFirmnessBoundaries(double bmi, string expected)
|
||||
=> Assert.Equal(expected, BmiFirmnessPolicy.Firmness((decimal)bmi));
|
||||
|
||||
[Fact]
|
||||
public void MissingOrInvalidBodyDataNeverProducesFirmness()
|
||||
{
|
||||
Assert.Null(BmiFirmnessPolicy.Calculate(null, 80));
|
||||
Assert.Null(BmiFirmnessPolicy.Calculate(0, 80));
|
||||
Assert.Null(BmiFirmnessPolicy.Firmness(null));
|
||||
Assert.Equal(31.1m, BmiFirmnessPolicy.Calculate(180, 100.8m));
|
||||
}
|
||||
|
||||
[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);
|
||||
var mattress = new MattressModule(runner, Options.Create(Configuration()));
|
||||
Assert.Equal("全身", mattress.BuildParameterCommand("mattress_massage_right_part", "全身").Data!["option"]);
|
||||
Assert.Equal("轻柔", mattress.BuildParameterCommand("mattress_massage_level", "轻柔").Data!["option"]);
|
||||
Assert.Throws<InvalidOperationException>(() => mattress.BuildParameterCommand("mattress_right_mode", "随意模式"));
|
||||
}
|
||||
|
||||
[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 FallbackOnlyContainsConfiguredModulesAndIncludesMattressEntities()
|
||||
{
|
||||
var runner = Runner(new());
|
||||
var options = Options.Create(Configuration());
|
||||
var catalog = new HomeAssistantDeviceCatalog(options, new DeviceModuleRegistry([new MattressModule(runner, options)]));
|
||||
var device = Assert.Single(catalog.BuildDiscoveredDevices(null, new Dictionary<string, HomeAssistantState>()));
|
||||
Assert.Equal("mattress", device.ModuleKey);
|
||||
Assert.Contains(device.Entities, entity => entity.EntityId == "select.right_massage");
|
||||
Assert.Null(device.PrimaryControlEntityId);
|
||||
}
|
||||
|
||||
[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 ManualLightOffUsesSceneLinkageToStopBothMassageSides()
|
||||
{
|
||||
var gateway = new FakeHaGateway();
|
||||
var runner = Runner(gateway);
|
||||
var options = Options.Create(Configuration());
|
||||
var mattress = new MattressModule(runner, options);
|
||||
var light = new LightingModule(runner, options, new FakeLight(), new LightOffDispatcher([mattress]));
|
||||
await light.ExecuteParameterAsync(light.BuildParameterCommand("brightness", 0), default);
|
||||
Assert.Equal("off", gateway.States["light.bedroom"].State);
|
||||
Assert.Equal("无", gateway.States["select.right_massage"].State);
|
||||
Assert.Equal("无", gateway.States["select.left_massage"].State);
|
||||
}
|
||||
|
||||
[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(2, provider.GetServices<IHostedService>().Count());
|
||||
Assert.Contains(provider.GetRequiredService<DeviceModuleRegistry>().Modules, module => module.Key == "mattress");
|
||||
}
|
||||
|
||||
[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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
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 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" => "on", "turn_off" => "off",
|
||||
"select_option" => data["option"]!.ToString()!,
|
||||
"set_value" => data["value"]!.ToString()!, _ => States.GetValueOrDefault(entity)?.State ?? "unknown"
|
||||
};
|
||||
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;
|
||||
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,144 @@
|
||||
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[] { "智能适配", "轻柔", "全身" },
|
||||
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")]
|
||||
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,187 @@
|
||||
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();
|
||||
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,291 @@
|
||||
using Evergreen.Health.Application.Health.Devices;
|
||||
using Evergreen.Health.Application.Health.Devices.Contracts;
|
||||
using Evergreen.Health.Application.Health.Devices.Mattress;
|
||||
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", MattressRightMassagePart = "select.right",
|
||||
MattressLeftMassagePart = "select.left", MattressLeftHeatingLevel = "number.heat" }
|
||||
};
|
||||
private static HomeAssistantState Light(string state, double brightness, DateTimeOffset updated) => new()
|
||||
{
|
||||
EntityId = "light.bedroom", State = state, LastUpdated = updated,
|
||||
Attributes = JsonSerializer.SerializeToElement(new { brightness })
|
||||
};
|
||||
private static DeviceCommand Massage() => new("按摩", "select.right", "select", "select_option",
|
||||
new Dictionary<string, object?> { ["option"] = "全身" }) { Intent = DeviceCommandIntent.StartMassage };
|
||||
|
||||
[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(2, 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.Contains(actions, action => action.Kind == SleepWorkflowActionKind.StartGentleRightMassage);
|
||||
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));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("off", 255)]
|
||||
[InlineData("on", 0)]
|
||||
[InlineData("unavailable", 255)]
|
||||
public async Task OffOrUnconfirmedLampBlocksMassage(string state, double brightness)
|
||||
{
|
||||
var gateway = new FakeHaGateway();
|
||||
gateway.States["light.bedroom"] = Light(state, brightness, Now);
|
||||
var guard = new LightMassageSafetyRule(gateway, Options.Create(Configuration()), new());
|
||||
var runner = new DeviceActionRunner(gateway, TimeProvider.System, new(), [guard]);
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(() => runner.SendAsync(Massage(), default));
|
||||
Assert.Empty(gateway.Calls);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task KnownLampOffRejectsOlderCloudOnCacheButAllowsFreshOnReading()
|
||||
{
|
||||
var gateway = new FakeHaGateway();
|
||||
var state = new SceneLightSafetyState(); state.MarkOff(Now);
|
||||
var guard = new LightMassageSafetyRule(gateway, Options.Create(Configuration()), state);
|
||||
gateway.States["light.bedroom"] = Light("on", 100, Now.AddSeconds(-1));
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(() => guard.ValidateAsync(Massage(), default));
|
||||
gateway.States["light.bedroom"] = Light("on", 100, Now.AddSeconds(1));
|
||||
await guard.ValidateAsync(Massage(), default);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task LateMassageAfterAnOffCommandCannotUndoShutdown()
|
||||
{
|
||||
var gateway = new FakeHaGateway();
|
||||
var guard = new LightMassageSafetyRule(gateway, Options.Create(Configuration()), new());
|
||||
var runner = new DeviceActionRunner(gateway, TimeProvider.System, new(), [guard]);
|
||||
gateway.States["light.bedroom"] = Light("on", 100, DateTimeOffset.UtcNow);
|
||||
await runner.SendAsync(new("关灯", "light.bedroom", "light", "turn_off")
|
||||
{ Intent = DeviceCommandIntent.TurnRelaxationLightOff }, default);
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(() => runner.SendAsync(Massage(), default));
|
||||
Assert.DoesNotContain(gateway.Calls, command => command.EntityId == "select.right");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task NaturalFadeToZeroStopsBothMassageSides()
|
||||
{
|
||||
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 mattress = new MattressModule(runner, Options.Create(Configuration()));
|
||||
var safety = new SceneLightSafetyState();
|
||||
var light = new HomeAssistantLightTransitionService(gateway, new([mattress]), 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));
|
||||
}
|
||||
await WaitUntilAsync(() => gateway.States.GetValueOrDefault("select.left")?.State == "无");
|
||||
Assert.Equal("off", gateway.States["light.bedroom"].State);
|
||||
Assert.Equal("无", gateway.States["select.right"].State);
|
||||
Assert.NotNull(safety.OffAt);
|
||||
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 ManuallyEnabledHeatIsIncludedEvenIfAutomaticMattressStartIsDisabled()
|
||||
{
|
||||
var gateway = new FakeHaGateway();
|
||||
var module = new MattressModule(new(gateway, TimeProvider.System, new()), Options.Create(Configuration()));
|
||||
Assert.Contains(module.GetShutdownCommands(), command => command.EntityId == "number.heat" && command.ExpectedState == "0");
|
||||
}
|
||||
|
||||
[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,116 @@
|
||||
using Evergreen.Health.Application.Health.Devices;
|
||||
using Evergreen.Health.Application.Health.Devices.Discovery;
|
||||
using Evergreen.Health.Application.Health.Devices.Mattress;
|
||||
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() { MattressLeftHeatingLevel = "number.heating" }
|
||||
});
|
||||
var runner = new DeviceActionRunner(new FakeHaGateway(), TimeProvider.System, new());
|
||||
var catalog = new HomeAssistantDeviceCatalog(options, new DeviceModuleRegistry([new MattressModule(runner, options)]));
|
||||
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" },
|
||||
["number.heating"] = new() { EntityId = "number.heating", State = "0" }
|
||||
};
|
||||
Assert.Empty(catalog.BuildDiscoveredDevices(registry, states));
|
||||
Assert.Equal("mattress", Assert.Single(catalog.BuildDiscoveredDevices(null, states)).ModuleKey);
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,7 @@ using Admin.NET.Core.ElasticSearch;
|
||||
using Admin.NET.Core.Service;
|
||||
using AspNetCoreRateLimit;
|
||||
using Evergreen.Health.Application.Health.Integrations.HomeAssistant;
|
||||
using Evergreen.Health.Application.Health.Devices;
|
||||
using Evergreen.Health.Application.Health.Integrations.MiAirX;
|
||||
using Furion;
|
||||
using Furion.Logging;
|
||||
@@ -17,12 +18,14 @@ using Furion.VirtualFileServer;
|
||||
using IPTools.Core;
|
||||
using Microsoft.AspNetCore.Authentication.JwtBearer;
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.AspNetCore.Diagnostics;
|
||||
using Microsoft.AspNetCore.Hosting;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.HttpOverrides;
|
||||
using Microsoft.AspNetCore.ResponseCompression;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Newtonsoft.Json;
|
||||
using OnceMi.AspNetCore.OSS;
|
||||
using Scalar.AspNetCore;
|
||||
@@ -78,10 +81,7 @@ public class Startup : AppStartup
|
||||
services.AddHttpRemote();
|
||||
services.AddHttpClient<HomeAssistantClient>();
|
||||
services.AddHttpClient<MiAirXClient>();
|
||||
services.AddSingleton<TcmMusicTransitionService>();
|
||||
services.AddHostedService(provider => provider.GetRequiredService<TcmMusicTransitionService>());
|
||||
services.AddSingleton<HomeAssistantLightTransitionService>();
|
||||
services.AddHostedService(provider => provider.GetRequiredService<HomeAssistantLightTransitionService>());
|
||||
services.AddHealthDevices();
|
||||
// 任务队列
|
||||
services.AddTaskQueue();
|
||||
// 脱敏检测
|
||||
@@ -104,7 +104,8 @@ public class Startup : AppStartup
|
||||
}
|
||||
;
|
||||
|
||||
services.AddControllersWithViews()
|
||||
// 后端只提供 API;移动端页面位于独立的 Mobile 项目。
|
||||
services.AddControllers()
|
||||
.AddAppLocalization()
|
||||
.AddNewtonsoftJson(options => SetNewtonsoftJsonSetting(options.SerializerSettings))
|
||||
//.AddXmlSerializerFormatters()
|
||||
@@ -287,7 +288,19 @@ public class Startup : AppStartup
|
||||
}
|
||||
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();
|
||||
}
|
||||
|
||||
@@ -373,9 +386,8 @@ public class Startup : AppStartup
|
||||
// 注册集线器
|
||||
endpoints.MapHubs();
|
||||
|
||||
endpoints.MapControllerRoute(
|
||||
name: "default",
|
||||
pattern: "{controller=Home}/{action=Index}/{id?}");
|
||||
// 健康接口和后台接口均使用特性路由,不再保留默认欢迎页。
|
||||
endpoints.MapControllers();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -13,7 +13,9 @@ public class WebComponent : IWebComponent
|
||||
// 设置日志过滤
|
||||
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
|
||||
|
||||
@@ -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 |
@@ -18,6 +18,9 @@
|
||||
"AmbientLight": "light.lemesh_cn_2041198316_wy0d02_s_2_light",
|
||||
"Speaker": "media_player.xiaomi_cn_1221084723_l17a",
|
||||
"SpeakerSleepMode": "switch.xiaomi_cn_1221084723_l17a_sleep_mode_p_7_3",
|
||||
"Climate": "climate.fawad_cn_974720212_3010",
|
||||
"Curtain": "cover.czmydz_cn_2251706154_mym1_s_2_curtain",
|
||||
"Outlet": "switch.ym001_cn_2108613283_ymcz_on_p_2_1",
|
||||
"MattressLeftHeatingLevel": "number.qushui_cn_2108064504_zerox_left_level_p_6_1",
|
||||
"MattressRightHeatingLevel": "number.qushui_cn_2108064504_zerox_right_level_p_6_2",
|
||||
"MattressLeftMode": "select.qushui_cn_2108064504_zerox_left_mode_p_5_1",
|
||||
|
||||
@@ -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,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,27 @@
|
||||
using Evergreen.Health.Application.Health.Integrations.HomeAssistant;
|
||||
|
||||
namespace Evergreen.Health.Application.Health.Devices.Contracts;
|
||||
|
||||
public enum DeviceCommandIntent { Normal, StartMassage, TurnRelaxationLightOn, TurnRelaxationLightOff }
|
||||
|
||||
/// <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 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,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,52 @@
|
||||
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; }
|
||||
|
||||
public override async Task ExecuteSleepAsync(DeviceSceneContext context, PersonalizedSceneConfig config,
|
||||
string constitutionCode, CancellationToken cancellationToken)
|
||||
{
|
||||
await context.RunAsync(new("开启除湿机", PrimaryEntityId, "humidifier", "turn_on"), cancellationToken);
|
||||
await context.RunAsync(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,335 @@
|
||||
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;
|
||||
public HomeAssistantDeviceCatalog(IOptions<HomeAssistantOptions> options, DeviceModuleRegistry modules)
|
||||
=> (_options, _modules) = (options.Value, modules);
|
||||
|
||||
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);
|
||||
|
||||
// HA 将 Sun、Backup 等虚拟系统服务标记为 service,不属于家具或实体传感器。
|
||||
foreach (var device in registries.Devices.Where(device => string.IsNullOrWhiteSpace(device.DisabledBy) &&
|
||||
!string.Equals(device.EntryType, "service", StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
var regEntities = entitiesByDevice.GetValueOrDefault(device.Id) ?? [];
|
||||
if (regEntities.Count == 0) 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 module = _modules.ForEntities(entityOutputs.Select(entity => entity.EntityId));
|
||||
var category = module?.Category ?? CategorizeDevice(displayName, device.Model ?? "", device.Manufacturer ?? "", entityOutputs);
|
||||
var metrics = ExtractDeviceMetrics(category, entityOutputs);
|
||||
var primaryControlEntityId = 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,
|
||||
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),
|
||||
Entities = entityOutputs, Metrics = ExtractDeviceMetrics(module.Category, entityOutputs)
|
||||
});
|
||||
}
|
||||
return list;
|
||||
}
|
||||
|
||||
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("firmness") || id.Contains("soft")) metrics["firmness"] = 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",
|
||||
"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,34 @@
|
||||
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)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(_entities.Temperature) ||
|
||||
!states.TryGetValue(_entities.Temperature, out var state) || !DeviceActionRunner.IsAvailable(state))
|
||||
return new(null, null, false, "室内温度实体未配置或不可用");
|
||||
var observedAt = state.LastReported ?? state.LastUpdated;
|
||||
if (observedAt is null || _time.GetUtcNow() - observedAt > TimeSpan.FromMinutes(30) ||
|
||||
observedAt > _time.GetUtcNow().AddMinutes(1))
|
||||
return new(null, observedAt, false, "室内温度数据缺少有效时间或已过期");
|
||||
if (!double.TryParse(state.State, NumberStyles.Float, CultureInfo.InvariantCulture, out var celsius) ||
|
||||
!double.IsFinite(celsius)) return new(null, observedAt, false, "室内温度数据无效");
|
||||
var unit = state.GetStringAttribute("unit_of_measurement");
|
||||
if (unit is "°F" or "F") celsius = (celsius - 32) * 5 / 9;
|
||||
else if (!string.IsNullOrWhiteSpace(unit) && unit is not ("°C" or "C" or "℃"))
|
||||
return new(null, observedAt, false, "室内温度单位未识别");
|
||||
if (celsius is < -20 or > 60) return new(null, observedAt, false, "室内温度超出有效范围");
|
||||
return new(celsius, observedAt, true, "室内温度数据有效");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
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;
|
||||
public FragranceModule(DeviceActionRunner runner, IOptions<HomeAssistantOptions> options) : base(runner)
|
||||
{
|
||||
_entities = options.Value.Entities;
|
||||
Parameters =
|
||||
[
|
||||
DeviceValues.Number("fragrance_duration", _entities.FragranceDuration, 2, 6, "出香时长"),
|
||||
new("fragrance_interval", _entities.FragranceInterval, value =>
|
||||
{
|
||||
var interval = DeviceValues.Integer(value, 10, 20, "出香间隔");
|
||||
if (interval % 5 != 0) throw new InvalidOperationException("出香间隔仅支持 10、15 或 20 分钟");
|
||||
return new("设置出香间隔", _entities.FragranceInterval, "number", "set_value",
|
||||
new Dictionary<string, object?> { ["value"] = interval });
|
||||
}, "出香间隔", 10, 20),
|
||||
DeviceValues.Switch("fragrance_light", _entities.FragranceLight, "light", "香氛灯"),
|
||||
new("fragrance_quick_release", _entities.FragranceQuickRelease, _ => new("快速出香",
|
||||
_entities.FragranceQuickRelease, "button", "press"), "快速出香"),
|
||||
DeviceValues.Switch("fragrance_auto_release", _entities.FragranceAutoRelease, "switch", "人来增香"),
|
||||
DeviceValues.Switch("fragrance_auto_light", _entities.FragranceAutoLight, "switch", "感应夜灯"),
|
||||
DeviceValues.Switch("fragrance_light_auto_off", _entities.FragranceLightAutoOff, "switch", "夜灯自动关闭")
|
||||
];
|
||||
}
|
||||
public override string Key => "fragrance";
|
||||
public override string Category => nameof(HealthDeviceCategory.Fragrance);
|
||||
public override string DisplayName => "智能香氛机";
|
||||
public override string PrimaryEntityId => _entities.FragranceSwitch;
|
||||
public override IReadOnlyList<DeviceParameterDefinition> Parameters { get; }
|
||||
|
||||
public override async Task ExecuteSleepAsync(DeviceSceneContext context, PersonalizedSceneConfig config,
|
||||
string constitutionCode, CancellationToken cancellationToken)
|
||||
{
|
||||
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() =>
|
||||
[
|
||||
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)
|
||||
=> 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,77 @@
|
||||
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.Mattress;
|
||||
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;
|
||||
|
||||
namespace Evergreen.Health.Application.Health.Devices;
|
||||
|
||||
public static class HealthDeviceRegistration
|
||||
{
|
||||
public static IServiceCollection AddHealthDevices(this IServiceCollection services)
|
||||
{
|
||||
services.AddSingleton(TimeProvider.System);
|
||||
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, LightMassageSafetyRule>();
|
||||
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<IHealthDeviceModule>(provider => provider.GetRequiredService<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<LightingModule>();
|
||||
services.AddSingleton<IHealthDeviceModule>(provider => provider.GetRequiredService<LightingModule>());
|
||||
services.AddSingleton<AudioModule>();
|
||||
services.AddSingleton<IHealthDeviceModule>(provider => provider.GetRequiredService<AudioModule>());
|
||||
services.AddSingleton<MattressModule>();
|
||||
services.AddSingleton<IHealthDeviceModule>(provider => provider.GetRequiredService<MattressModule>());
|
||||
services.AddSingleton<ILightOffHandler>(provider => provider.GetRequiredService<MattressModule>());
|
||||
services.AddSingleton<LightOffDispatcher>();
|
||||
services.AddSingleton<DeviceModuleRegistry>();
|
||||
services.AddSingleton<HomeAssistantDeviceCatalog>();
|
||||
services.AddSingleton<HealthSceneCoordinator>();
|
||||
return services;
|
||||
}
|
||||
}
|
||||
@@ -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.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)
|
||||
{
|
||||
await _transition.CancelAsync(cancellationToken);
|
||||
await base.ExecuteParameterAsync(command, cancellationToken);
|
||||
if (command.Service == "turn_off") await _lightOff.NotifyAsync(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,120 @@
|
||||
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.Services;
|
||||
|
||||
namespace Evergreen.Health.Application.Health.Devices.Mattress;
|
||||
|
||||
public sealed class MattressModule : HealthDeviceModule, ILightOffHandler
|
||||
{
|
||||
private readonly HomeAssistantOptions _options;
|
||||
private HomeAssistantEntityOptions Entities => _options.Entities;
|
||||
public MattressModule(DeviceActionRunner runner, IOptions<HomeAssistantOptions> options) : base(runner)
|
||||
{
|
||||
_options = options.Value;
|
||||
var modes = new[] { "无", "瑜伽助眠", "智能适配", "智能腰托" };
|
||||
var parts = new[] { "无", "背部", "腰部", "牵引", "腿部", "全身" };
|
||||
var parameters = new List<DeviceParameterDefinition>
|
||||
{
|
||||
DeviceValues.Number("mattress_left_heating", Entities.MattressLeftHeatingLevel, 0, 4, "床垫左侧加热档位"),
|
||||
DeviceValues.Number("mattress_right_heating", Entities.MattressRightHeatingLevel, 0, 4, "床垫右侧加热档位"),
|
||||
DeviceValues.Select("mattress_left_mode", Entities.MattressLeftMode, modes, "床垫左侧承托模式", true),
|
||||
DeviceValues.Select("mattress_right_mode", Entities.MattressRightMode, modes, "床垫右侧承托模式", true),
|
||||
DeviceValues.Select("mattress_massage_left_part", Entities.MattressLeftMassagePart, parts, "左侧按摩部位",
|
||||
intent: DeviceCommandIntent.StartMassage),
|
||||
DeviceValues.Select("mattress_massage_right_part", Entities.MattressRightMassagePart, parts, "右侧按摩部位",
|
||||
intent: DeviceCommandIntent.StartMassage),
|
||||
DeviceValues.Select("mattress_massage_level", Entities.MattressMassageLevel, ["轻柔", "强劲"], "按摩力度")
|
||||
};
|
||||
foreach (var (zone, entityId) in Entities.MattressFirmness)
|
||||
parameters.Add(DeviceValues.Select($"mattress_firmness_{zone}", entityId,
|
||||
["最软", "很软", "稍软", "稍硬", "很硬", "最硬"], $"床垫{ZoneName(zone)}软硬度"));
|
||||
Parameters = parameters;
|
||||
}
|
||||
public override string Key => "mattress";
|
||||
public override string Category => nameof(HealthDeviceCategory.Sleep);
|
||||
public override string DisplayName => "智能床垫";
|
||||
public override string PrimaryEntityId => ""; // 床垫没有单一总开关,不能把加热实体冒充开关。
|
||||
public override IReadOnlyList<DeviceParameterDefinition> Parameters { get; }
|
||||
|
||||
public override async Task ExecuteSleepAsync(DeviceSceneContext context, PersonalizedSceneConfig config,
|
||||
string constitutionCode, CancellationToken cancellationToken)
|
||||
{
|
||||
if (!_options.SleepScene.EnableMattress) return;
|
||||
await context.RunAsync(BuildParameterCommand("mattress_left_heating", config.MattressHeatingLevel), cancellationToken);
|
||||
await context.RunAsync(BuildParameterCommand("mattress_right_heating", config.MattressHeatingLevel), cancellationToken);
|
||||
var firmness = MattressComfortService.FirmnessName(config.MattressLeftFirmness);
|
||||
if (firmness is null)
|
||||
context.Results.Add(new() { Name = "按 BMI 调节床垫软硬度", Status = "Skipped",
|
||||
Message = "健康档案缺少身高或体重,未自动修改软硬度" });
|
||||
else
|
||||
foreach (var zone in Entities.MattressFirmness.Keys)
|
||||
await context.RunAsync(BuildParameterCommand($"mattress_firmness_{zone}", firmness), cancellationToken);
|
||||
var modeCommand = BuildParameterCommand("mattress_left_mode", "智能适配");
|
||||
await context.RunAsync(modeCommand, cancellationToken);
|
||||
if (context.Results.Last().Status == "Succeeded")
|
||||
await Runner.VerifyBatchAsync([modeCommand], context.Results, cancellationToken);
|
||||
// 气泵调节结束后再启动按摩,保持真实设备所需的顺序。
|
||||
await Runner.DelayAsync(TimeSpan.FromSeconds(2), cancellationToken);
|
||||
await context.RunAsync(BuildParameterCommand("mattress_massage_left_part", "无"), cancellationToken);
|
||||
await context.RunAsync(BuildParameterCommand("mattress_massage_level", "轻柔"), cancellationToken);
|
||||
var massage = BuildParameterCommand("mattress_massage_right_part", "全身") with { ExpectedState = "全身" };
|
||||
await context.RunAsync(massage, cancellationToken);
|
||||
if (context.Results.Last().Status == "Succeeded")
|
||||
{
|
||||
await Runner.DelayAsync(TimeSpan.FromSeconds(3), cancellationToken);
|
||||
await Runner.VerifyBatchAsync([massage], context.Results, cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
public IEnumerable<DeviceCommand> MassageOffCommands() =>
|
||||
[
|
||||
new("关闭床垫右侧按摩", Entities.MattressRightMassagePart, "select", "select_option",
|
||||
new Dictionary<string, object?> { ["option"] = "无" }, "无"),
|
||||
new("关闭床垫左侧按摩", Entities.MattressLeftMassagePart, "select", "select_option",
|
||||
new Dictionary<string, object?> { ["option"] = "无" }, "无")
|
||||
];
|
||||
|
||||
public override IEnumerable<DeviceCommand> GetShutdownCommands()
|
||||
{
|
||||
foreach (var command in MassageOffCommands()) yield return command;
|
||||
// 未参与自动启动不等于无需关闭:手动开启的加热也要随一键关闭停止。
|
||||
yield return new("关闭床垫左侧加热", Entities.MattressLeftHeatingLevel, "number", "set_value",
|
||||
new Dictionary<string, object?> { ["value"] = 0 }, "0");
|
||||
yield return new("关闭床垫右侧加热", Entities.MattressRightHeatingLevel, "number", "set_value",
|
||||
new Dictionary<string, object?> { ["value"] = 0 }, "0");
|
||||
}
|
||||
|
||||
public async Task HandleAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var commands = MassageOffCommands().Where(command => !string.IsNullOrWhiteSpace(command.EntityId)).ToArray();
|
||||
var results = new List<HomeAssistantActionResult>();
|
||||
foreach (var command in commands)
|
||||
await Runner.RunAsync(new Dictionary<string, HomeAssistantState>(), results, command, cancellationToken, false);
|
||||
await Runner.VerifyBatchAsync(commands, results, cancellationToken);
|
||||
if (results.Any(result => result.Status == "Failed"))
|
||||
throw new InvalidOperationException("床垫按摩未确认关闭:" +
|
||||
string.Join(";", results.Where(result => result.Status == "Failed").Select(result => result.Message)));
|
||||
}
|
||||
|
||||
public override HomeAssistantScenePresetItem? BuildPreview(PersonalizedSceneConfig config, string constitutionCode)
|
||||
{
|
||||
if (!_options.SleepScene.EnableMattress || !EntityIds.Any()) return null;
|
||||
var firmness = MattressComfortService.FirmnessName(config.MattressLeftFirmness) ?? "未自动调节";
|
||||
var preview = Preview("按室温和 BMI 调节床垫并开启右侧按摩",
|
||||
$"左右加热 {config.MattressHeatingLevel} 档 · 软硬度{firmness} · 右侧全身轻柔按摩",
|
||||
new() { ["heating_level"] = config.MattressHeatingLevel, ["firmness"] = firmness,
|
||||
["right_massage_part"] = "全身", ["massage_level"] = "轻柔" });
|
||||
preview.EntityId = EntityIds.First();
|
||||
return preview;
|
||||
}
|
||||
|
||||
public static string ZoneName(string zone) => zone switch
|
||||
{
|
||||
"left_shoulder" => "左肩", "right_shoulder" => "右肩", "left_back" => "左背", "right_back" => "右背",
|
||||
"left_waist" => "左腰", "right_waist" => "右腰", "left_lumbar" => "左腰椎", "right_lumbar" => "右腰椎",
|
||||
"left_hip" => "左臀", "right_hip" => "右臀", "left_leg" => "左腿", "right_leg" => "右腿", _ => zone
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
// 本版不接入舒睡/唤醒/一键关闭:沿用基类的无场景动作、无关闭命令、无场景预览。
|
||||
}
|
||||
+51
-4
@@ -5,7 +5,7 @@ using System.Text.Json;
|
||||
|
||||
namespace Evergreen.Health.Application.Health.Integrations.HomeAssistant;
|
||||
|
||||
public sealed class HomeAssistantClient
|
||||
public sealed class HomeAssistantClient : IHomeAssistantGateway
|
||||
{
|
||||
private readonly HttpClient _httpClient;
|
||||
private readonly HomeAssistantOptions _options;
|
||||
@@ -20,9 +20,47 @@ public sealed class HomeAssistantClient
|
||||
public async Task<IReadOnlyList<HomeAssistantState>> GetStatesAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
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);
|
||||
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(
|
||||
@@ -33,10 +71,19 @@ public sealed class HomeAssistantClient
|
||||
{
|
||||
using var request = CreateRequest(HttpMethod.Post, $"api/services/{domain}/{service}");
|
||||
request.Content = JsonContent.Create(data);
|
||||
using var response = await _httpClient.SendAsync(request, cancellationToken);
|
||||
using var response = await SendAsync(request, 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)
|
||||
{
|
||||
if (!_options.Enabled)
|
||||
|
||||
+26
-46
@@ -1,4 +1,5 @@
|
||||
using Evergreen.Health.Application.Health.Models;
|
||||
using Evergreen.Health.Application.Health.Scenes;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
@@ -6,13 +7,16 @@ using Microsoft.Extensions.Options;
|
||||
namespace Evergreen.Health.Application.Health.Integrations.HomeAssistant;
|
||||
|
||||
/// <summary>在服务端驱动卧室灯光渐变,浏览器关闭后任务仍继续。</summary>
|
||||
public sealed class HomeAssistantLightTransitionService : IHostedService
|
||||
public sealed class HomeAssistantLightTransitionService : IHostedService, ILightTransitionRuntime
|
||||
{
|
||||
private const int SleepStartPercent = 50;
|
||||
private const int WakeTargetPercent = 70;
|
||||
private static readonly TimeSpan StepInterval = TimeSpan.FromSeconds(5);
|
||||
|
||||
private readonly HomeAssistantClient _homeAssistant;
|
||||
private readonly IHomeAssistantGateway _homeAssistant;
|
||||
private readonly LightOffDispatcher _lightOff;
|
||||
private readonly SceneLightSafetyState _safety;
|
||||
private readonly TimeProvider _time;
|
||||
private readonly HomeAssistantOptions _options;
|
||||
private readonly ILogger<HomeAssistantLightTransitionService> _logger;
|
||||
private readonly SemaphoreSlim _operationLock = new(1, 1);
|
||||
@@ -22,13 +26,19 @@ public sealed class HomeAssistantLightTransitionService : IHostedService
|
||||
private HomeAssistantLightTransitionOutput _status = new();
|
||||
|
||||
public HomeAssistantLightTransitionService(
|
||||
IHttpClientFactory httpClientFactory,
|
||||
IHomeAssistantGateway homeAssistant,
|
||||
LightOffDispatcher lightOff,
|
||||
IOptions<HomeAssistantOptions> options,
|
||||
ILogger<HomeAssistantLightTransitionService> logger)
|
||||
ILogger<HomeAssistantLightTransitionService> logger,
|
||||
SceneLightSafetyState safety,
|
||||
TimeProvider time)
|
||||
{
|
||||
_options = options.Value;
|
||||
_logger = logger;
|
||||
_homeAssistant = new HomeAssistantClient(httpClientFactory.CreateClient(nameof(HomeAssistantLightTransitionService)), options);
|
||||
_homeAssistant = homeAssistant;
|
||||
_lightOff = lightOff;
|
||||
_safety = safety;
|
||||
_time = time;
|
||||
}
|
||||
|
||||
public Task StartAsync(CancellationToken cancellationToken) => Task.CompletedTask;
|
||||
@@ -70,6 +80,7 @@ public sealed class HomeAssistantLightTransitionService : IHostedService
|
||||
["color_temp_kelvin"] = Math.Clamp(colorTemperatureKelvin, 2200, 5000)
|
||||
}, cancellationToken);
|
||||
|
||||
_safety.MarkOn();
|
||||
SetStatus(true, true, "sleep", SleepStartPercent, 0, "助眠渐暗已启动,每5秒降低1%亮度");
|
||||
Launch("sleep", SleepStartPercent);
|
||||
return GetStatus();
|
||||
@@ -84,7 +95,9 @@ public sealed class HomeAssistantLightTransitionService : IHostedService
|
||||
{
|
||||
await StopCurrentAsync();
|
||||
var light = await ReadAvailableLightAsync(cancellationToken);
|
||||
var isOff = string.Equals(light.State, "off", StringComparison.OrdinalIgnoreCase);
|
||||
var knownOffAt = _safety.OffAt;
|
||||
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("灯具未返回当前亮度,无法从当前亮度启动晨起唤醒");
|
||||
@@ -92,6 +105,7 @@ public sealed class HomeAssistantLightTransitionService : IHostedService
|
||||
|
||||
if (current >= WakeTargetPercent && !isOff)
|
||||
{
|
||||
_safety.MarkOn();
|
||||
SetStatus(true, false, "wake", current, WakeTargetPercent, "当前亮度已达到70%或更高,保持原亮度");
|
||||
return GetStatus();
|
||||
}
|
||||
@@ -105,6 +119,7 @@ public sealed class HomeAssistantLightTransitionService : IHostedService
|
||||
}, cancellationToken);
|
||||
}
|
||||
|
||||
_safety.MarkOn();
|
||||
SetStatus(true, true, "wake", current, WakeTargetPercent, "晨起唤醒已启动,每5秒增加1%亮度");
|
||||
Launch("wake", current);
|
||||
return GetStatus();
|
||||
@@ -151,15 +166,16 @@ public sealed class HomeAssistantLightTransitionService : IHostedService
|
||||
{
|
||||
while (true)
|
||||
{
|
||||
await Task.Delay(StepInterval, cancellationToken);
|
||||
await Task.Delay(StepInterval, _time, cancellationToken);
|
||||
var light = await ReadAvailableLightAsync(cancellationToken);
|
||||
if (!string.Equals(light.State, "on", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
_safety.MarkOff(_time.GetUtcNow());
|
||||
if (mode == "sleep")
|
||||
{
|
||||
try
|
||||
{
|
||||
await StopMattressMassageAsync(cancellationToken);
|
||||
await _lightOff.NotifyAsync(cancellationToken);
|
||||
SetStatus(true, false, mode, 0, 0, "灯已关闭,床垫按摩已停止");
|
||||
}
|
||||
catch (Exception exception) when (exception is not OperationCanceledException)
|
||||
@@ -191,10 +207,11 @@ public sealed class HomeAssistantLightTransitionService : IHostedService
|
||||
{
|
||||
["entity_id"] = LightEntityId
|
||||
}, cancellationToken);
|
||||
_safety.MarkOff(_time.GetUtcNow());
|
||||
SetStatus(true, false, mode, 0, 0, "灯光已关闭,正在停止床垫按摩");
|
||||
try
|
||||
{
|
||||
await StopMattressMassageAsync(cancellationToken);
|
||||
await _lightOff.NotifyAsync(cancellationToken);
|
||||
SetStatus(true, false, mode, 0, 0, "助眠渐暗已完成,灯光与床垫按摩已关闭");
|
||||
}
|
||||
catch (Exception exception) when (exception is not OperationCanceledException)
|
||||
@@ -227,43 +244,6 @@ public sealed class HomeAssistantLightTransitionService : IHostedService
|
||||
}
|
||||
}
|
||||
|
||||
private async Task StopMattressMassageAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var massageEntities = new[]
|
||||
{
|
||||
_options.Entities.MattressLeftMassagePart,
|
||||
_options.Entities.MattressRightMassagePart
|
||||
}.Where(entityId => !string.IsNullOrWhiteSpace(entityId)).Distinct().ToArray();
|
||||
var failures = new List<string>();
|
||||
foreach (var entityId in massageEntities)
|
||||
{
|
||||
try
|
||||
{
|
||||
var confirmed = false;
|
||||
for (var attempt = 0; attempt < 2 && !confirmed; attempt++)
|
||||
{
|
||||
await _homeAssistant.CallServiceAsync("select", "select_option", new Dictionary<string, object?>
|
||||
{
|
||||
["entity_id"] = entityId,
|
||||
["option"] = "无"
|
||||
}, cancellationToken);
|
||||
await Task.Delay(TimeSpan.FromSeconds(1), cancellationToken);
|
||||
var current = (await _homeAssistant.GetStatesAsync(cancellationToken))
|
||||
.FirstOrDefault(item => string.Equals(item.EntityId, entityId, StringComparison.OrdinalIgnoreCase));
|
||||
confirmed = current?.State == "无";
|
||||
}
|
||||
if (!confirmed) failures.Add(entityId);
|
||||
}
|
||||
catch (Exception exception) when (exception is not OperationCanceledException)
|
||||
{
|
||||
_logger.LogWarning(exception, "灯光熄灭后未能关闭床垫按摩 {EntityId}", entityId);
|
||||
failures.Add(entityId);
|
||||
}
|
||||
}
|
||||
if (failures.Count > 0)
|
||||
throw new InvalidOperationException($"灯光已关闭,但床垫按摩未确认关闭:{string.Join("、", failures)}");
|
||||
}
|
||||
|
||||
private async Task StopCurrentAsync()
|
||||
{
|
||||
if (_activeCancellation is null) return;
|
||||
|
||||
+11
-4
@@ -1,4 +1,5 @@
|
||||
using System.Text.Json;
|
||||
using System.Globalization;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace Evergreen.Health.Application.Health.Integrations.HomeAssistant;
|
||||
@@ -17,17 +18,20 @@ public sealed class HomeAssistantState
|
||||
[JsonPropertyName("last_updated")]
|
||||
public DateTimeOffset? LastUpdated { get; set; }
|
||||
|
||||
[JsonPropertyName("last_reported")]
|
||||
public DateTimeOffset? LastReported { get; set; }
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
public double? GetDoubleAttribute(string name)
|
||||
{
|
||||
if (!Attributes.TryGetProperty(name, out var value)) return null;
|
||||
if (value.ValueKind == JsonValueKind.Number && value.TryGetDouble(out var d)) return d;
|
||||
if (double.TryParse(value.ToString(), out var parsed)) return parsed;
|
||||
if (Attributes.ValueKind != JsonValueKind.Object || !Attributes.TryGetProperty(name, out var value)) return null;
|
||||
if (value.ValueKind == JsonValueKind.Number && value.TryGetDouble(out var d) && double.IsFinite(d)) return d;
|
||||
if (double.TryParse(value.ToString(), NumberStyles.Float, CultureInfo.InvariantCulture, out var parsed) && double.IsFinite(parsed)) return parsed;
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -54,6 +58,9 @@ public sealed class HomeAssistantDeviceRegistryItem
|
||||
|
||||
[JsonPropertyName("disabled_by")]
|
||||
public string? DisabledBy { get; set; }
|
||||
|
||||
[JsonPropertyName("entry_type")]
|
||||
public string? EntryType { get; set; }
|
||||
}
|
||||
|
||||
public sealed class HomeAssistantEntityRegistryItem
|
||||
|
||||
@@ -14,6 +14,9 @@ public sealed class HomeAssistantOptions : IConfigurableOptions
|
||||
|
||||
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 DehumidifierFanLevel { get; set; } = string.Empty;
|
||||
public string DehumidifierUv { get; set; } = string.Empty;
|
||||
|
||||
+3
-3
@@ -45,7 +45,7 @@ public sealed class HomeAssistantRegistryService : ISingleton
|
||||
}
|
||||
return _cachedRegistries;
|
||||
}
|
||||
catch (Exception ex)
|
||||
catch (Exception ex) when (ex is not OperationCanceledException || !cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
_logger.LogWarning("通过 WebSocket 读取 Home Assistant 注册表失败:{Message}", ex.Message);
|
||||
return _cachedRegistries;
|
||||
@@ -119,7 +119,7 @@ public sealed class HomeAssistantRegistryService : ISingleton
|
||||
|
||||
try
|
||||
{
|
||||
await ws.CloseAsync(WebSocketCloseStatus.NormalClosure, "Completed", CancellationToken.None);
|
||||
await ws.CloseAsync(WebSocketCloseStatus.NormalClosure, "Completed", cts.Token);
|
||||
}
|
||||
catch
|
||||
{
|
||||
@@ -134,7 +134,7 @@ public sealed class HomeAssistantRegistryService : ISingleton
|
||||
CachedAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
}
|
||||
catch (Exception ex)
|
||||
catch (Exception ex) when (ex is not OperationCanceledException || !cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
_logger.LogWarning("连接 Home Assistant WebSocket 发生异常:{Message}", ex.Message);
|
||||
return null;
|
||||
|
||||
+8
@@ -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);
|
||||
}
|
||||
+11
@@ -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,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);
|
||||
}
|
||||
@@ -6,7 +6,7 @@ using System.Text.Json;
|
||||
|
||||
namespace Evergreen.Health.Application.Health.Integrations.MiAirX;
|
||||
|
||||
public sealed class MiAirXClient
|
||||
public sealed class MiAirXClient : IAudioPlaybackGateway
|
||||
{
|
||||
private readonly HttpClient _httpClient;
|
||||
private readonly MiAirXOptions _options;
|
||||
@@ -23,7 +23,6 @@ public sealed class MiAirXClient
|
||||
public bool IsConfigured => _options.Enabled &&
|
||||
!string.IsNullOrWhiteSpace(_options.BaseUrl) &&
|
||||
!string.IsNullOrWhiteSpace(_options.DeviceId) &&
|
||||
!string.IsNullOrWhiteSpace(_options.MusicUrl) &&
|
||||
(!_options.RequireWebPassword || !string.IsNullOrWhiteSpace(_options.WebPassword));
|
||||
|
||||
public Task PlayAsync(CancellationToken cancellationToken = default)
|
||||
@@ -48,6 +47,15 @@ public sealed class MiAirXClient
|
||||
}
|
||||
|
||||
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))
|
||||
|
||||
+22
-20
@@ -6,11 +6,14 @@ using System.Diagnostics;
|
||||
namespace Evergreen.Health.Application.Health.Integrations.MiAirX;
|
||||
|
||||
/// <summary>Demo 音乐任务由服务端持续驱动,页面关闭后仍按体质曲目循环渐弱播放。</summary>
|
||||
public sealed class TcmMusicTransitionService : IHostedService
|
||||
public sealed class TcmMusicTransitionService : IHostedService, ITcmMusicRuntime
|
||||
{
|
||||
private readonly MiAirXClient _miAirX;
|
||||
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();
|
||||
@@ -18,14 +21,15 @@ public sealed class TcmMusicTransitionService : IHostedService
|
||||
private Task? _activeTask;
|
||||
|
||||
public TcmMusicTransitionService(
|
||||
IHttpClientFactory httpClientFactory,
|
||||
IOptions<MiAirXOptions> miAirXOptions,
|
||||
IAudioPlaybackGateway audioGateway,
|
||||
IOptions<TcmMusicOptions> musicOptions,
|
||||
ILogger<TcmMusicTransitionService> logger)
|
||||
ILogger<TcmMusicTransitionService> logger,
|
||||
TimeProvider time)
|
||||
{
|
||||
_miAirX = new MiAirXClient(httpClientFactory.CreateClient(nameof(TcmMusicTransitionService)), miAirXOptions);
|
||||
_miAirX = audioGateway;
|
||||
_options = musicOptions.Value;
|
||||
_logger = logger;
|
||||
_time = time;
|
||||
}
|
||||
|
||||
public Task StartAsync(CancellationToken cancellationToken) => Task.CompletedTask;
|
||||
@@ -53,24 +57,22 @@ public sealed class TcmMusicTransitionService : IHostedService
|
||||
|
||||
public bool HasPlayableTracks(string constitutionCode)
|
||||
=> _miAirX.IsConfigured &&
|
||||
_options.Tracks.TryGetValue(constitutionCode, out var tracks) && tracks.Count > 0 &&
|
||||
_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 async Task StartForConstitutionAsync(string constitutionCode, CancellationToken cancellationToken)
|
||||
{
|
||||
if (!HasPlayableTracks(constitutionCode))
|
||||
throw new InvalidOperationException($"{constitutionCode} 的体质音乐 OSS URL 尚未配置完整");
|
||||
if (_options.StartVolume is < 1 or > 100 || _options.FadeStepSeconds is < 1 or > 60)
|
||||
throw new InvalidOperationException("体质音乐渐弱参数无效");
|
||||
|
||||
var tracks = _options.Tracks[constitutionCode].ToArray();
|
||||
await _operationLock.WaitAsync(cancellationToken);
|
||||
try
|
||||
{
|
||||
await CancelCurrentLoopAsync();
|
||||
await _miAirX.SetVolumeAsync(_options.StartVolume, cancellationToken);
|
||||
await _miAirX.SetVolumeAsync(DemoStartVolume, cancellationToken);
|
||||
await _miAirX.PlayUrlAsync(tracks[0].Url, cancellationToken);
|
||||
UpdateStatus(true, _options.StartVolume, "体质音乐正在自动渐弱");
|
||||
UpdateStatus(true, DemoStartVolume, "体质音乐正在自动渐弱");
|
||||
_activeCancellation = new CancellationTokenSource();
|
||||
_activeTask = RunFadeAsync(tracks, constitutionCode, _activeCancellation.Token);
|
||||
}
|
||||
@@ -119,23 +121,23 @@ public sealed class TcmMusicTransitionService : IHostedService
|
||||
string constitutionCode,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var clock = Stopwatch.StartNew();
|
||||
var currentVolume = _options.StartVolume;
|
||||
var nextFadeSecond = (double)_options.FadeStepSeconds;
|
||||
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), cancellationToken);
|
||||
var elapsed = clock.Elapsed.TotalSeconds;
|
||||
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 += _options.FadeStepSeconds;
|
||||
nextFadeSecond += DemoFadeStepSeconds;
|
||||
}
|
||||
|
||||
if (currentVolume == 0)
|
||||
@@ -149,7 +151,7 @@ public sealed class TcmMusicTransitionService : IHostedService
|
||||
if (elapsed - trackStartSecond < tracks[trackIndex].DurationSeconds) continue;
|
||||
trackIndex = (trackIndex + 1) % tracks.Count;
|
||||
await _miAirX.PlayUrlAsync(tracks[trackIndex].Url, cancellationToken);
|
||||
trackStartSecond = clock.Elapsed.TotalSeconds;
|
||||
trackStartSecond = _time.GetElapsedTime(start).TotalSeconds;
|
||||
_logger.LogInformation("体质 {ConstitutionCode} 循环播放 {Track},继承音量 {Volume}",
|
||||
constitutionCode, tracks[trackIndex].Title, currentVolume);
|
||||
}
|
||||
@@ -192,7 +194,7 @@ public sealed class TcmMusicTransitionService : IHostedService
|
||||
}
|
||||
|
||||
private static bool IsValidTrack(TcmMusicTrackOptions track)
|
||||
=> track.DurationSeconds > 0 &&
|
||||
=> 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";
|
||||
}
|
||||
|
||||
@@ -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("涂鸦接入仅预留,本版没有发送控制命令");
|
||||
}
|
||||
}
|
||||
@@ -114,6 +114,11 @@ public sealed class MattressComfortOutput
|
||||
public string? WeatherObservedAt { get; set; }
|
||||
public string WeatherReason { get; set; } = string.Empty;
|
||||
public string FirmnessReason { get; set; } = string.Empty;
|
||||
public double? IndoorTemperatureCelsius { get; set; }
|
||||
public bool IndoorTemperatureAvailable { get; set; }
|
||||
public DateTimeOffset? IndoorTemperatureObservedAt { get; set; }
|
||||
public string HeatingReason { get; set; } = string.Empty;
|
||||
public ClimateAdviceOutput ClimateAdvice { get; set; } = new();
|
||||
}
|
||||
|
||||
public sealed class TcmAssessmentInput
|
||||
@@ -161,6 +166,17 @@ public sealed class PersonalizedSceneConfig
|
||||
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
|
||||
|
||||
@@ -9,7 +9,11 @@ public enum HealthDeviceCategory
|
||||
Fragrance,
|
||||
Lighting,
|
||||
Media,
|
||||
Other
|
||||
Other,
|
||||
Climate,
|
||||
Curtain,
|
||||
SleepMonitoring,
|
||||
Outlet
|
||||
}
|
||||
|
||||
public sealed class HomeAssistantEntityOutput
|
||||
@@ -25,6 +29,7 @@ public sealed class HomeAssistantEntityOutput
|
||||
|
||||
public sealed class HomeAssistantDiscoveredDeviceOutput
|
||||
{
|
||||
public string ModuleKey { get; set; } = string.Empty;
|
||||
public string DeviceId { get; set; } = string.Empty;
|
||||
public string Name { get; set; } = string.Empty;
|
||||
public string Manufacturer { get; set; } = string.Empty;
|
||||
@@ -141,6 +146,7 @@ public sealed class HomeAssistantSetParameterOutput
|
||||
|
||||
public sealed class HomeAssistantSceneOutput
|
||||
{
|
||||
public ClimateAdviceOutput? ClimateAdvice { get; set; }
|
||||
public string SceneCode { get; set; } = string.Empty;
|
||||
public string SceneName { get; set; } = string.Empty;
|
||||
public bool Success { get; set; }
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
namespace Evergreen.Health.Application.Health.Policies;
|
||||
|
||||
/// <summary>Demo 起点,可由用户手动修改,不是医学或人体工学判断。</summary>
|
||||
public static class BmiFirmnessPolicy
|
||||
{
|
||||
public static decimal? Calculate(decimal? heightCm, decimal? weightKg)
|
||||
{
|
||||
if (heightCm is null or <= 0 || weightKg is null or <= 0) return null;
|
||||
try
|
||||
{
|
||||
var height = heightCm.Value / 100m;
|
||||
return Math.Round(weightKg.Value / (height * height), 1);
|
||||
}
|
||||
catch (ArithmeticException) { return null; }
|
||||
}
|
||||
|
||||
public static string? Firmness(decimal? bmi) => bmi switch
|
||||
{
|
||||
< 18.5m => "很软", < 24m => "稍软", < 28m => "稍硬",
|
||||
< 32m => "很硬", >= 32m => "最硬", _ => null
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
using Evergreen.Health.Application.Health.Models;
|
||||
|
||||
namespace Evergreen.Health.Application.Health.Policies;
|
||||
|
||||
public sealed record RecommendedRoomRange(int Minimum, int Maximum)
|
||||
{
|
||||
public string Display => $"{Minimum}–{Maximum}℃";
|
||||
public static RecommendedRoomRange ForConstitution(string code) => code switch
|
||||
{
|
||||
"yang_deficient" => new(24, 26), "yin_deficient" or "damp_heat" => new(21, 24),
|
||||
"phlegm_damp" => new(22, 24), "blood_stasis" => new(23, 25), _ => new(22, 25)
|
||||
};
|
||||
}
|
||||
|
||||
public sealed record IndoorTemperatureSample(double? Celsius, DateTimeOffset? ObservedAt, bool Available, string Reason);
|
||||
|
||||
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,160 @@
|
||||
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("参数实体尚未配置");
|
||||
var state = (await _gateway.GetStatesAsync(cancellationToken)).FirstOrDefault(item =>
|
||||
string.Equals(item.EntityId, entityId, StringComparison.OrdinalIgnoreCase));
|
||||
if (state is null || !IsAvailable(state)) throw new InvalidOperationException("目标参数实体当前离线或不可用");
|
||||
return state;
|
||||
}
|
||||
|
||||
public async Task SendAsync(DeviceCommand command, CancellationToken cancellationToken)
|
||||
{
|
||||
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
|
||||
{
|
||||
if (command.ValidateCurrentState is not null)
|
||||
command.ValidateCurrentState(await ReadAvailableStateAsync(command.EntityId, bounded.Token));
|
||||
foreach (var rule in _safety) await rule.ValidateAsync(command, bounded.Token);
|
||||
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());
|
||||
}
|
||||
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)
|
||||
{
|
||||
var results = new List<HomeAssistantActionResult>
|
||||
{
|
||||
new() { Name = command.Name, EntityId = command.EntityId, Status = "Succeeded" }
|
||||
};
|
||||
await VerifyBatchAsync([command], results, cancellationToken);
|
||||
if (results[0].Status != "Succeeded") throw new InvalidOperationException(results[0].Message);
|
||||
}
|
||||
|
||||
public async Task VerifyBatchAsync(IReadOnlyList<DeviceCommand> commands,
|
||||
List<HomeAssistantActionResult> results, CancellationToken cancellationToken)
|
||||
{
|
||||
var pending = commands.Where(command => !string.IsNullOrWhiteSpace(command.EntityId) &&
|
||||
command.ExpectedState is not null).ToList();
|
||||
for (var round = 0; round < _options.VerificationAttempts && pending.Count > 0; round++)
|
||||
{
|
||||
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!))
|
||||
{
|
||||
action.Status = "Succeeded"; action.Message = round == 0 ? "已回读确认目标状态" : "重试后已回读确认目标状态";
|
||||
pending.Remove(command); continue;
|
||||
}
|
||||
if (round == _options.VerificationAttempts - 1)
|
||||
{
|
||||
action.Status = "Failed"; action.Message = $"未确认目标{(command.ExpectedAttribute is null ? "状态" : command.ExpectedAttribute)} {command.ExpectedState}(当前:{actual ?? "未读取到"})";
|
||||
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,49 @@
|
||||
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 LightMassageSafetyRule : IDeviceActionSafetyRule
|
||||
{
|
||||
private readonly IHomeAssistantGateway _gateway;
|
||||
private readonly string _lightEntity;
|
||||
private readonly SceneLightSafetyState _state;
|
||||
public LightMassageSafetyRule(IHomeAssistantGateway gateway, IOptions<HomeAssistantOptions> options,
|
||||
SceneLightSafetyState state) => (_gateway, _lightEntity, _state) = (gateway, options.Value.Entities.AmbientLight, state);
|
||||
|
||||
public async Task ValidateAsync(DeviceCommand command, CancellationToken cancellationToken)
|
||||
{
|
||||
if (command.Intent != DeviceCommandIntent.StartMassage || string.IsNullOrWhiteSpace(_lightEntity)) return;
|
||||
var light = (await _gateway.GetStatesAsync(cancellationToken)).FirstOrDefault(item =>
|
||||
string.Equals(item.EntityId, _lightEntity, StringComparison.OrdinalIgnoreCase));
|
||||
var offAt = _state.OffAt;
|
||||
if (light is null || !DeviceActionRunner.IsAvailable(light) ||
|
||||
!string.Equals(light.State, "on", StringComparison.OrdinalIgnoreCase) ||
|
||||
light.GetDoubleAttribute("brightness") is <= 0 ||
|
||||
(offAt is not null && (light.LastUpdated is null || light.LastUpdated <= offAt)))
|
||||
throw new InvalidOperationException("卧室灯光已关闭或状态未确认,不启动床垫按摩");
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,6 @@
|
||||
using Evergreen.Health.Application.Health.Entities;
|
||||
using Evergreen.Health.Application.Health.Models;
|
||||
using Evergreen.Health.Application.Health.Policies;
|
||||
using Furion.DatabaseAccessor;
|
||||
using Furion.FriendlyException;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
@@ -144,6 +145,7 @@ public static class TcmConstitutionRules
|
||||
result.MusicRecommendation = MusicFor(result.ConstitutionCode);
|
||||
result.SceneConfig ??= new PersonalizedSceneConfig();
|
||||
result.SceneConfig.SpeakerVolumePercent = 50;
|
||||
result.RoomTemperatureRecommendation = RecommendedRoomRange.ForConstitution(result.ConstitutionCode).Display;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
@@ -202,7 +204,7 @@ public static class TcmConstitutionRules
|
||||
AromaRecommendation = input.Answers["allergy"] == "often"
|
||||
? "建议不使用香氛,避免过敏原" : aroma,
|
||||
MusicRecommendation = music,
|
||||
RoomTemperatureRecommendation = room,
|
||||
RoomTemperatureRecommendation = RecommendedRoomRange.ForConstitution(primary).Display,
|
||||
Scores = scores,
|
||||
Answers = Choices.Keys.ToDictionary(key => key, key => input.Answers[key]),
|
||||
Notes = notes,
|
||||
|
||||
@@ -1,65 +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 System.Text.Json;
|
||||
using Evergreen.Health.Application.Health.Policies;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace Evergreen.Health.Application.Health.Services;
|
||||
|
||||
// 重庆演示场景:BMI 只给出可手动修改的软硬起点,不是医学或人体工学诊断。
|
||||
/// <summary>组合独立的 BMI 和室温策略,不负责发送家具控制命令。</summary>
|
||||
public sealed class MattressComfortService : ITransient
|
||||
{
|
||||
// 演示使用 Open-Meteo 接口;正式商用前应确认许可或替换为有授权的天气源。
|
||||
private const string WeatherUrl =
|
||||
"https://api.open-meteo.com/v1/forecast?latitude=29.56&longitude=106.55¤t=temperature_2m%2Capparent_temperature&timezone=Asia%2FShanghai";
|
||||
private static readonly HttpClient WeatherClient = new() { Timeout = TimeSpan.FromSeconds(6) };
|
||||
private static readonly SemaphoreSlim WeatherLock = new(1, 1);
|
||||
private static WeatherSample? CachedWeather;
|
||||
private readonly IHomeAssistantGateway _gateway;
|
||||
private readonly IndoorEnvironmentReader _environment;
|
||||
private readonly HomeAssistantEntityOptions _entities;
|
||||
public MattressComfortService(IHomeAssistantGateway gateway, IndoorEnvironmentReader environment,
|
||||
IOptions<HomeAssistantOptions> options)
|
||||
=> (_gateway, _environment, _entities) = (gateway, environment, options.Value.Entities);
|
||||
|
||||
public async Task<MattressComfortOutput> GetAsync(HealthProfile? profile, CancellationToken cancellationToken = default)
|
||||
{
|
||||
decimal? bmi = null;
|
||||
if (profile?.HeightCm > 0 && profile.WeightKg > 0)
|
||||
IReadOnlyDictionary<string, HomeAssistantState> states;
|
||||
try
|
||||
{
|
||||
var height = profile.HeightCm.Value / 100m;
|
||||
bmi = Math.Round(profile.WeightKg.Value / (height * height), 1);
|
||||
states = (await _gateway.GetStatesAsync(cancellationToken)).GroupBy(state => state.EntityId, StringComparer.OrdinalIgnoreCase)
|
||||
.ToDictionary(group => group.Key, group => group.Last(), StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
var firmness = bmi switch
|
||||
catch (Exception exception) when (exception is not OperationCanceledException)
|
||||
{
|
||||
< 18.5m => "很软",
|
||||
< 24m => "稍软",
|
||||
< 28m => "稍硬",
|
||||
< 32m => "很硬",
|
||||
>= 32m => "最硬",
|
||||
_ => null
|
||||
};
|
||||
var chinaNow = DateTimeOffset.UtcNow.ToOffset(TimeSpan.FromHours(8));
|
||||
var winter = chinaNow.Month is 12 or 1 or 2;
|
||||
var weather = await ReadWeatherAsync(cancellationToken);
|
||||
var heating = 0;
|
||||
if (winter && weather?.FeelsLikeCelsius is double feelsLike)
|
||||
heating = feelsLike < 8 ? 2 : feelsLike < 16 ? 1 : 0;
|
||||
states = new Dictionary<string, HomeAssistantState>();
|
||||
}
|
||||
return Get(profile, states);
|
||||
}
|
||||
|
||||
var weatherReason = !winter
|
||||
? "重庆非冬季,床垫加热关闭"
|
||||
: weather == null
|
||||
? "重庆天气暂不可用,为安全起见不自动加热"
|
||||
: heating == 0
|
||||
? "重庆处于冬季,但当前体感温度较高,不自动加热"
|
||||
: $"重庆冬季体感 {weather.FeelsLikeCelsius:0.#}℃,建议加热 {heating} 档";
|
||||
|
||||
return new MattressComfortOutput
|
||||
public MattressComfortOutput Get(HealthProfile? profile, IReadOnlyDictionary<string, HomeAssistantState> states)
|
||||
{
|
||||
var bmi = BmiFirmnessPolicy.Calculate(profile?.HeightCm, profile?.WeightKg);
|
||||
var firmness = BmiFirmnessPolicy.Firmness(bmi);
|
||||
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()
|
||||
{
|
||||
Bmi = bmi,
|
||||
Firmness = firmness,
|
||||
HeatingLevel = heating,
|
||||
IsWinter = winter,
|
||||
OutdoorTemperatureCelsius = weather?.TemperatureCelsius,
|
||||
FeelsLikeCelsius = weather?.FeelsLikeCelsius,
|
||||
WeatherAvailable = weather != null,
|
||||
WeatherObservedAt = weather?.ObservedAt,
|
||||
WeatherReason = weatherReason,
|
||||
FirmnessReason = firmness == null
|
||||
? "健康档案尚无身高和体重,暂不自动调节软硬度"
|
||||
Bmi = bmi, Firmness = firmness, HeatingLevel = advice.Mode == "heat" ? 1 : 0,
|
||||
IndoorTemperatureCelsius = sample.Celsius, IndoorTemperatureAvailable = sample.Available,
|
||||
IndoorTemperatureObservedAt = sample.ObservedAt, HeatingReason = advice.Reason,
|
||||
WeatherReason = advice.Reason, // 旧客户端兼容字段,不再读取或依赖室外天气。
|
||||
ClimateAdvice = advice,
|
||||
FirmnessReason = firmness is null
|
||||
? "健康档案尚无有效身高和体重,暂不自动调节软硬度"
|
||||
: $"按 BMI {bmi:0.0} 给出“{firmness}”演示起点,可在设备详情手动调整"
|
||||
};
|
||||
}
|
||||
@@ -67,61 +56,18 @@ public sealed class MattressComfortService : ITransient
|
||||
public static void ApplyTo(PersonalizedSceneConfig config, MattressComfortOutput recommendation)
|
||||
{
|
||||
config.MattressHeatingLevel = recommendation.HeatingLevel;
|
||||
config.MattressLeftFirmness = recommendation.Firmness == null ? 0 : FirmnessLevel(recommendation.Firmness);
|
||||
config.MattressLeftFirmness = recommendation.Firmness is null ? 0 : FirmnessLevel(recommendation.Firmness);
|
||||
config.MattressRightFirmness = config.MattressLeftFirmness;
|
||||
config.ClimateAdvice = recommendation.ClimateAdvice;
|
||||
}
|
||||
|
||||
public static int FirmnessLevel(string firmness) => firmness switch
|
||||
{
|
||||
"最软" => 1, "很软" => 2, "稍软" => 3,
|
||||
"稍硬" => 4, "很硬" => 5, "最硬" => 6,
|
||||
_ => 0
|
||||
"最软" => 1, "很软" => 2, "稍软" => 3, "稍硬" => 4, "很硬" => 5, "最硬" => 6, _ => 0
|
||||
};
|
||||
|
||||
public static string? FirmnessName(int level) => level switch
|
||||
{
|
||||
1 => "最软", 2 => "很软", 3 => "稍软",
|
||||
4 => "稍硬", 5 => "很硬", 6 => "最硬",
|
||||
_ => null
|
||||
1 => "最软", 2 => "很软", 3 => "稍软", 4 => "稍硬", 5 => "很硬", 6 => "最硬", _ => null
|
||||
};
|
||||
|
||||
private static async Task<WeatherSample?> ReadWeatherAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
if (CachedWeather is { } cached && DateTimeOffset.UtcNow - cached.FetchedAt < TimeSpan.FromMinutes(30))
|
||||
return cached;
|
||||
|
||||
await WeatherLock.WaitAsync(cancellationToken);
|
||||
try
|
||||
{
|
||||
if (CachedWeather is { } fresh && DateTimeOffset.UtcNow - fresh.FetchedAt < TimeSpan.FromMinutes(30))
|
||||
return fresh;
|
||||
|
||||
using var response = await WeatherClient.GetAsync(WeatherUrl, cancellationToken);
|
||||
response.EnsureSuccessStatusCode();
|
||||
await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken);
|
||||
using var json = await JsonDocument.ParseAsync(stream, cancellationToken: cancellationToken);
|
||||
var current = json.RootElement.GetProperty("current");
|
||||
var temperature = current.GetProperty("temperature_2m").GetDouble();
|
||||
var feelsLike = current.GetProperty("apparent_temperature").GetDouble();
|
||||
var observedAt = current.GetProperty("time").GetString();
|
||||
CachedWeather = new WeatherSample(temperature, feelsLike, observedAt, DateTimeOffset.UtcNow);
|
||||
return CachedWeather;
|
||||
}
|
||||
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch
|
||||
{
|
||||
// 天气失败时不使用过期值自动开启加热,冬季也安全关闭。
|
||||
return null;
|
||||
}
|
||||
finally
|
||||
{
|
||||
WeatherLock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
private sealed record WeatherSample(
|
||||
double TemperatureCelsius, double FeelsLikeCelsius, string? ObservedAt, DateTimeOffset FetchedAt);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
namespace Evergreen.Health.Application.Health.Sleep;
|
||||
|
||||
/// <summary>本版没有异常判定算法;未来规则只产生事件,不直接操作音箱或家具。</summary>
|
||||
public sealed record SleepAnomaly(string Code, string Description, DateTimeOffset ObservedAt);
|
||||
public interface ISleepAnomalyRule
|
||||
{
|
||||
string Key { get; }
|
||||
SleepAnomaly? Evaluate(IReadOnlyList<SleepObservation> observations);
|
||||
}
|
||||
|
||||
/// <summary>每个舒睡会话独立持有;只有语音实际播报后才记录提醒时间。</summary>
|
||||
public sealed class AnomalyReminderGate
|
||||
{
|
||||
private readonly TimeSpan _interval;
|
||||
private readonly Dictionary<string, DateTimeOffset> _delivered = new(StringComparer.OrdinalIgnoreCase);
|
||||
private readonly object _lock = new();
|
||||
public AnomalyReminderGate(SleepWorkflowOptions options) => _interval = options.SameAnomalyReminderInterval;
|
||||
public bool CanRemind(string code, DateTimeOffset now)
|
||||
{
|
||||
lock (_lock) return !_delivered.TryGetValue(code, out var last) || now - last >= _interval;
|
||||
}
|
||||
public void MarkDelivered(string code, DateTimeOffset now)
|
||||
{
|
||||
lock (_lock) _delivered[code] = now;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
namespace Evergreen.Health.Application.Health.Sleep;
|
||||
|
||||
public sealed record GuidanceClip(string Key, string Title, string? Url, double? DurationSeconds)
|
||||
{
|
||||
public bool Playable => DurationSeconds is > 0 && double.IsFinite(DurationSeconds.Value) &&
|
||||
Uri.TryCreate(Url, UriKind.Absolute, out var uri) && uri.Scheme is "http" or "https";
|
||||
}
|
||||
|
||||
/// <summary>假数据仅是目录元数据,没有伪造 OSS 地址或播放成功状态。</summary>
|
||||
public sealed class GuidanceCatalog
|
||||
{
|
||||
public GuidanceCatalog(GuidanceClip? initialRelaxation = null)
|
||||
=> InitialRelaxation = initialRelaxation ?? new("initial-relaxation", "首次上床放松引导(待上传)", null, null);
|
||||
public GuidanceClip InitialRelaxation { get; }
|
||||
public GuidanceClip AnomalyReminder { get; } = new("anomaly-reminder", "睡眠状态语音提醒(预留)", null, null);
|
||||
}
|
||||
|
||||
public sealed record GuidancePlaybackResult(bool Completed, string Message);
|
||||
public interface IGuidancePlayer
|
||||
{
|
||||
Task<GuidancePlaybackResult> PlayAsync(GuidanceClip clip, int volumePercent, CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
public sealed class ReservedGuidancePlayer : IGuidancePlayer
|
||||
{
|
||||
public Task<GuidancePlaybackResult> PlayAsync(GuidanceClip clip, int volumePercent, CancellationToken cancellationToken)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
return Task.FromResult(new GuidancePlaybackResult(false, "引导音频尚未接入,本版不执行播放"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
namespace Evergreen.Health.Application.Health.Sleep;
|
||||
|
||||
public enum ReservedSleepPhase { Disabled, WaitingForBed, Settling, Guiding, Music, Stopped }
|
||||
public enum SleepWorkflowActionKind { PrepareEnvironment, StartGentleRightMassage, RequestGuidance, RestartMusicAtFifty, StartLightFade }
|
||||
public sealed record SleepWorkflowAction(SleepWorkflowActionKind Kind, GuidanceClip? Clip = null, int? VolumePercent = null);
|
||||
|
||||
/// <summary>
|
||||
/// 只规划未来动作:没有 HostedService、设备命令或 API 路由,本版默认 Disabled。
|
||||
/// 实际接入时由场景协调层消费动作,不能从传感器适配器直接控制家具。
|
||||
/// </summary>
|
||||
public sealed class ReservedSleepWorkflow
|
||||
{
|
||||
private readonly SleepWorkflowOptions _options;
|
||||
private readonly GuidanceCatalog _guidance;
|
||||
private DateTimeOffset? _occupiedSince;
|
||||
private DateTimeOffset? _fadeAt;
|
||||
private GuidanceClip? _pendingClip;
|
||||
private bool _fadeIssued;
|
||||
private bool _initialGuide;
|
||||
public ReservedSleepPhase Phase { get; private set; } = ReservedSleepPhase.Disabled;
|
||||
|
||||
public ReservedSleepWorkflow(SleepWorkflowOptions options, GuidanceCatalog guidance)
|
||||
=> (_options, _guidance) = (options, guidance);
|
||||
|
||||
public IReadOnlyList<SleepWorkflowAction> Start()
|
||||
{
|
||||
_occupiedSince = null; _fadeAt = null; _pendingClip = null; _fadeIssued = false; _initialGuide = false;
|
||||
Phase = _options.Enabled ? ReservedSleepPhase.WaitingForBed : ReservedSleepPhase.Disabled;
|
||||
return _options.Enabled ? [new(SleepWorkflowActionKind.PrepareEnvironment)] : [];
|
||||
}
|
||||
|
||||
public IReadOnlyList<SleepWorkflowAction> Observe(SleepObservation observation, DateTimeOffset now)
|
||||
{
|
||||
if (Phase is not (ReservedSleepPhase.WaitingForBed or ReservedSleepPhase.Settling)) return [];
|
||||
var fresh = observation.Availability == SleepDataAvailability.Available && observation.ObservedAt is not null &&
|
||||
now - observation.ObservedAt <= _options.MaximumObservationAge && observation.ObservedAt <= now.AddMinutes(1);
|
||||
if (!fresh || observation.Presence != BedPresence.Occupied)
|
||||
{
|
||||
_occupiedSince = null; Phase = ReservedSleepPhase.WaitingForBed; return [];
|
||||
}
|
||||
_occupiedSince ??= now;
|
||||
Phase = ReservedSleepPhase.Settling;
|
||||
if (now - _occupiedSince < _options.StableBedDuration) return [];
|
||||
_pendingClip = _guidance.InitialRelaxation;
|
||||
_initialGuide = true;
|
||||
Phase = ReservedSleepPhase.Guiding;
|
||||
return [new(SleepWorkflowActionKind.StartGentleRightMassage),
|
||||
new(SleepWorkflowActionKind.RequestGuidance, _pendingClip, _options.GuidanceVolumePercent)];
|
||||
}
|
||||
|
||||
public IReadOnlyList<SleepWorkflowAction> GuidanceCompleted(string clipKey, bool completed, DateTimeOffset now)
|
||||
{
|
||||
if (Phase != ReservedSleepPhase.Guiding || !completed || _pendingClip?.Key != clipKey || !_pendingClip.Playable) return [];
|
||||
Phase = ReservedSleepPhase.Music;
|
||||
if (_initialGuide) _fadeAt = now + _options.PostGuidanceFadeDelay;
|
||||
return [new(SleepWorkflowActionKind.RestartMusicAtFifty, VolumePercent: 50)];
|
||||
}
|
||||
|
||||
public IReadOnlyList<SleepWorkflowAction> RequestReminder(GuidanceClip clip)
|
||||
{
|
||||
if (!_options.Enabled || Phase != ReservedSleepPhase.Music) return [];
|
||||
_initialGuide = false; _pendingClip = clip; Phase = ReservedSleepPhase.Guiding;
|
||||
return [new(SleepWorkflowActionKind.RequestGuidance, clip, _options.GuidanceVolumePercent)];
|
||||
}
|
||||
|
||||
public IReadOnlyList<SleepWorkflowAction> Tick(DateTimeOffset now)
|
||||
{
|
||||
if (Phase != ReservedSleepPhase.Music || _fadeIssued || _fadeAt is null || now < _fadeAt) return [];
|
||||
_fadeIssued = true; return [new(SleepWorkflowActionKind.StartLightFade)];
|
||||
}
|
||||
|
||||
public void Stop()
|
||||
{
|
||||
Phase = ReservedSleepPhase.Stopped; _occupiedSince = null; _fadeAt = null; _pendingClip = null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
namespace Evergreen.Health.Application.Health.Sleep;
|
||||
|
||||
public enum BedPresence { Unknown, Empty, Occupied }
|
||||
public enum SleepDataAvailability { Unconfigured, Offline, Stale, Available }
|
||||
public enum ObservedSleepStage { Unknown, Awake, Light, Deep }
|
||||
|
||||
/// <summary>设备读数归一化入口;不凭心率、睡眠阶段 0 或未知值推断有人/无人。</summary>
|
||||
public sealed record SleepObservation(string SourceId, DateTimeOffset? ObservedAt,
|
||||
SleepDataAvailability Availability, BedPresence Presence,
|
||||
ObservedSleepStage Stage = ObservedSleepStage.Unknown,
|
||||
double? HeartRate = null, double? RespiratoryRate = null, bool? Movement = null);
|
||||
|
||||
public interface ISleepObservationSource
|
||||
{
|
||||
string Key { get; }
|
||||
Task<SleepObservation> ReadAsync(CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>JE01 尚未接入,不启动轮询,也不将假数据视作“无人”。</summary>
|
||||
public sealed class ReservedJe01Source : ISleepObservationSource
|
||||
{
|
||||
public string Key => "je01";
|
||||
public Task<SleepObservation> ReadAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
return Task.FromResult(new SleepObservation(Key, null, SleepDataAvailability.Unconfigured, BedPresence.Unknown));
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class SleepWorkflowOptions
|
||||
{
|
||||
public bool Enabled { get; init; } = false;
|
||||
public TimeSpan StableBedDuration { get; init; } = TimeSpan.FromSeconds(30);
|
||||
public TimeSpan PostGuidanceFadeDelay { get; init; } = TimeSpan.FromSeconds(30);
|
||||
public TimeSpan MaximumObservationAge { get; init; } = TimeSpan.FromMinutes(2);
|
||||
public TimeSpan SameAnomalyReminderInterval { get; init; } = TimeSpan.FromMinutes(30);
|
||||
public int GuidanceVolumePercent { get; init; } = 50;
|
||||
}
|
||||
Reference in New Issue
Block a user