492 lines
25 KiB
C#
492 lines
25 KiB
C#
using Evergreen.Health.Application.Health.Devices;
|
|
using Evergreen.Health.Application.Health.Devices.Climate;
|
|
using Evergreen.Health.Application.Health.Devices.Contracts;
|
|
using Evergreen.Health.Application.Health.Devices.Curtain;
|
|
using Evergreen.Health.Application.Health.Devices.Discovery;
|
|
using Evergreen.Health.Application.Health.Devices.Environment;
|
|
using Evergreen.Health.Application.Health.Integrations.HomeAssistant;
|
|
using Evergreen.Health.Application.Health.Models;
|
|
using Evergreen.Health.Application.Health.Scenes;
|
|
using Microsoft.Extensions.Options;
|
|
using System.Text.Json;
|
|
using Xunit;
|
|
|
|
namespace Admin.NET.Test.Health;
|
|
|
|
/// <summary>只使用内存网关,不启动后端、不连接 HA、不触碰实际设备。</summary>
|
|
public sealed class ClimateCurtainIntegrationTests
|
|
{
|
|
private const string ClimateId = "climate.test_room", CurtainId = "cover.test_curtain";
|
|
private const string RoomTemperatureId = "sensor.test_room_temperature";
|
|
private static HomeAssistantOptions Settings() => new()
|
|
{ Entities = new() { Climate = ClimateId, Curtain = CurtainId, Temperature = RoomTemperatureId } };
|
|
private static DeviceActionRunner Runner(Gateway gateway) => new(gateway, TimeProvider.System,
|
|
new() { VerificationDelay = TimeSpan.Zero, VerificationAttempts = 3 });
|
|
private static ClimateModule Climate(Gateway gateway) => new(Runner(gateway), Options.Create(Settings()));
|
|
private static CurtainModule Curtain(Gateway gateway) => new(Runner(gateway), Options.Create(Settings()));
|
|
private static BedPresenceClimateHeatAction HeatAutomation(Gateway gateway)
|
|
{
|
|
var runner = Runner(gateway);
|
|
var options = Options.Create(Settings());
|
|
return new(new ClimateModule(runner, options), new IndoorEnvironmentReader(options, TimeProvider.System), runner, options);
|
|
}
|
|
|
|
private static HomeAssistantState RoomTemperature(string celsius = "20", int ageMinutes = 0) => new()
|
|
{
|
|
EntityId = RoomTemperatureId, State = celsius, LastReported = DateTimeOffset.UtcNow.AddMinutes(-ageMinutes),
|
|
Attributes = JsonSerializer.SerializeToElement(new { unit_of_measurement = "°C" })
|
|
};
|
|
private static HomeAssistantState ClimateState(string state = "off", int features = 393,
|
|
double minimum = 5, double maximum = 35, double step = .5) => new()
|
|
{
|
|
EntityId = ClimateId, State = state, Attributes = JsonSerializer.SerializeToElement(new Dictionary<string, object?>
|
|
{
|
|
["hvac_modes"] = new[] { "cool", "heat", "fan_only", "off" }, ["supported_features"] = features,
|
|
["temperature"] = 23m, ["current_temperature"] = 24.1m, ["temperature_unit"] = "°C",
|
|
["min_temp"] = minimum, ["max_temp"] = maximum, ["target_temp_step"] = step,
|
|
["fan_modes"] = new[] { "自动", "低风", "中风", "高风" }, ["fan_mode"] = "低风"
|
|
})
|
|
};
|
|
private static HomeAssistantState CurtainState(string state = "open", int features = 15) => new()
|
|
{
|
|
EntityId = CurtainId, State = state,
|
|
Attributes = JsonSerializer.SerializeToElement(new { supported_features = features, current_position = 40, device_class = "curtain" })
|
|
};
|
|
private static PersonalizedSceneConfig Scene(string mode = "heat", int target = 24) => new()
|
|
{
|
|
ClimateAdvice = new() { Mode = mode, RecommendedMinimumCelsius = 22, RecommendedMaximumCelsius = 25,
|
|
TargetTemperatureCelsius = target, Reason = "测试室温建议" }
|
|
};
|
|
|
|
[Fact]
|
|
public async Task Bed_presence_heat_sends_each_command_once_and_confirms_heat_and_22_degrees()
|
|
{
|
|
var gateway = new Gateway(ClimateState(), RoomTemperature());
|
|
var action = HeatAutomation(gateway);
|
|
Assert.Equal("target-not-configured", await action.ExecuteAsync("climate.other", default));
|
|
Assert.Equal("readback-confirmed", await action.ExecuteAsync(ClimateId, default));
|
|
Assert.Collection(gateway.Calls,
|
|
mode => { Assert.Equal("set_hvac_mode", mode.Service); Assert.Equal("heat", mode.Data!["hvac_mode"]); },
|
|
temperature => { Assert.Equal("set_temperature", temperature.Service); Assert.Equal(22m, temperature.Data!["temperature"]); });
|
|
Assert.Equal("heat", gateway.States[ClimateId].State);
|
|
Assert.Equal(22, gateway.States[ClimateId].GetDoubleAttribute("temperature"));
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData("22", 0, "off", 5, "temperature-not-low")]
|
|
[InlineData("27", 0, "off", 5, "temperature-not-low")]
|
|
[InlineData("20", 31, "off", 5, "temperature-stale")]
|
|
[InlineData("NaN", 0, "off", 5, "temperature-invalid")]
|
|
[InlineData(null, 0, "off", 5, "temperature-unavailable")]
|
|
[InlineData("20", 0, "cool", 5, "climate-not-off")]
|
|
[InlineData("20", 0, "unavailable", 5, "climate-unavailable")]
|
|
[InlineData("20", 0, "off", 24, "climate-heat-unsupported")]
|
|
public async Task Bed_presence_heat_checks_room_power_and_target_before_sending(
|
|
string? roomTemperature, int ageMinutes, string climateState, int minimum, string result)
|
|
{
|
|
var gateway = new Gateway(ClimateState(climateState, minimum: minimum));
|
|
if (roomTemperature is not null) gateway.States[RoomTemperatureId] = RoomTemperature(roomTemperature, ageMinutes);
|
|
Assert.Equal(result, await HeatAutomation(gateway).ExecuteAsync(ClimateId, default));
|
|
Assert.Empty(gateway.Calls);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Bed_presence_heat_leaves_the_temperature_alone_when_heat_mode_cannot_be_confirmed()
|
|
{
|
|
var gateway = new Gateway(ClimateState(), RoomTemperature()) { ApplyCommands = false };
|
|
Assert.Equal("climate-heat-mode-unconfirmed", await HeatAutomation(gateway).ExecuteAsync(ClimateId, default));
|
|
Assert.Equal("set_hvac_mode", Assert.Single(gateway.Calls).Service);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Bed_presence_heat_does_not_set_temperature_after_the_user_changes_the_mode()
|
|
{
|
|
var gateway = new Gateway(ClimateState(), RoomTemperature());
|
|
gateway.BeforeRead = read => { if (read == 3) gateway.States[ClimateId] = ClimateState("off"); };
|
|
Assert.Equal("climate-heat-state-changed", await HeatAutomation(gateway).ExecuteAsync(ClimateId, default));
|
|
Assert.Equal("set_hvac_mode", Assert.Single(gateway.Calls).Service);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Bed_presence_heat_requires_both_mode_and_target_in_the_final_readback()
|
|
{
|
|
var gateway = new Gateway(ClimateState(), RoomTemperature()) { ModeAfterTemperature = "off" };
|
|
Assert.Equal("climate-heat-target-unconfirmed", await HeatAutomation(gateway).ExecuteAsync(ClimateId, default));
|
|
Assert.Equal(22, gateway.States[ClimateId].GetDoubleAttribute("temperature"));
|
|
Assert.Equal(new[] { "set_hvac_mode", "set_temperature" }, gateway.Calls.Select(command => command.Service));
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData(5)]
|
|
[InlineData(23.5)]
|
|
[InlineData(35)]
|
|
public async Task TemperatureUsesActualBoundsHalfDegreeAndAttributeReadback(double value)
|
|
{
|
|
var gateway = new Gateway(ClimateState());
|
|
var module = Climate(gateway);
|
|
await module.ExecuteParameterAsync(module.BuildParameterCommand("climate_temperature", value), default);
|
|
Assert.Equal("off", gateway.States[ClimateId].State); // 顶层不是温度,但属性读回成功。
|
|
Assert.Equal(value, gateway.States[ClimateId].GetDoubleAttribute("temperature"));
|
|
Assert.Equal("set_temperature", Assert.Single(gateway.Calls).Service);
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData(4.5)]
|
|
[InlineData(35.5)]
|
|
[InlineData(23.25)]
|
|
public async Task InvalidTemperatureNeverSends(double value)
|
|
{
|
|
var gateway = new Gateway(ClimateState());
|
|
var module = Climate(gateway);
|
|
await Assert.ThrowsAsync<InvalidOperationException>(() => module.ExecuteParameterAsync(
|
|
module.BuildParameterCommand("climate_temperature", value), default));
|
|
Assert.Empty(gateway.Calls);
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData("NaN")]
|
|
[InlineData("Infinity")]
|
|
[InlineData("not-a-number")]
|
|
public void NonfiniteOrMalformedTemperatureIsRejected(string value)
|
|
=> Assert.Throws<InvalidOperationException>(() => Climate(new(ClimateState()))
|
|
.BuildParameterCommand("climate_temperature", value));
|
|
|
|
[Fact]
|
|
public async Task ActualModeAndFanOptionsAreValidatedAndReadBack()
|
|
{
|
|
var gateway = new Gateway(ClimateState());
|
|
var module = Climate(gateway);
|
|
await module.ExecuteParameterAsync(module.BuildParameterCommand("climate_mode", "cool"), default);
|
|
await module.ExecuteParameterAsync(module.BuildParameterCommand("climate_fan_mode", "中风"), default);
|
|
Assert.Equal("cool", gateway.States[ClimateId].State);
|
|
Assert.Equal("中风", gateway.States[ClimateId].GetStringAttribute("fan_mode"));
|
|
await Assert.ThrowsAsync<InvalidOperationException>(() => module.ExecuteParameterAsync(
|
|
module.BuildParameterCommand("climate_mode", "auto"), default));
|
|
await Assert.ThrowsAsync<InvalidOperationException>(() => module.ExecuteParameterAsync(
|
|
module.BuildParameterCommand("climate_fan_mode", "超强风"), default));
|
|
Assert.Equal(2, gateway.Calls.Count);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task FreshCapabilityValidationCannotBeBypassedBySendingCommandDirectly()
|
|
{
|
|
var gateway = new Gateway(ClimateState());
|
|
var runner = Runner(gateway);
|
|
var module = new ClimateModule(runner, Options.Create(Settings()));
|
|
var command = module.BuildParameterCommand("climate_temperature", 25);
|
|
gateway.States[ClimateId] = ClimateState(features: 392); // 构造命令后设备失去目标温度能力。
|
|
await Assert.ThrowsAsync<InvalidOperationException>(() => runner.SendAsync(command, default));
|
|
Assert.Empty(gateway.Calls);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task MissingTemperatureCapabilitiesAndWrongUnitDoNotUseGuessedDefaults()
|
|
{
|
|
var gateway = new Gateway(ClimateState());
|
|
var module = Climate(gateway);
|
|
foreach (var attribute in new[] { "min_temp", "max_temp", "target_temp_step", "temperature_unit" })
|
|
{
|
|
var attributes = ClimateState().Attributes.EnumerateObject().ToDictionary(item => item.Name, item => (object?)item.Value.Clone());
|
|
attributes.Remove(attribute);
|
|
gateway.States[ClimateId] = new() { EntityId = ClimateId, State = "off", Attributes = JsonSerializer.SerializeToElement(attributes) };
|
|
await Assert.ThrowsAsync<InvalidOperationException>(() => module.ExecuteParameterAsync(
|
|
module.BuildParameterCommand("climate_temperature", 24), default));
|
|
}
|
|
Assert.Empty(gateway.Calls);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task UnchangedAttributeFailsConfirmationWithoutRepeatedWrites()
|
|
{
|
|
var gateway = new Gateway(ClimateState()) { ApplyCommands = false };
|
|
var module = Climate(gateway);
|
|
await Assert.ThrowsAsync<InvalidOperationException>(() => module.ExecuteParameterAsync(
|
|
module.BuildParameterCommand("climate_temperature", 25), default));
|
|
Assert.Single(gateway.Calls);
|
|
Assert.Equal(4, gateway.Reads); // 发送前能力校验一次,随后三轮只读确认。
|
|
}
|
|
|
|
[Fact]
|
|
public async Task UnavailableEntityCannotConfirmEvenIfCachedAttributeMatches()
|
|
{
|
|
var gateway = new Gateway(ClimateState()) { BecomeUnavailableAfterSend = true };
|
|
var module = Climate(gateway);
|
|
await Assert.ThrowsAsync<InvalidOperationException>(() => module.ExecuteParameterAsync(
|
|
module.BuildParameterCommand("climate_temperature", 24), default));
|
|
Assert.Single(gateway.Calls);
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData("unavailable")]
|
|
[InlineData("unknown")]
|
|
public async Task OfflineClimateDoesNotSendEvenWhenModeIsKnown(string state)
|
|
{
|
|
var gateway = new Gateway(ClimateState(state));
|
|
var module = Climate(gateway);
|
|
await Assert.ThrowsAsync<InvalidOperationException>(() => module.ExecuteParameterAsync(
|
|
module.BuildParameterCommand("climate_mode", "heat"), default));
|
|
Assert.Empty(gateway.Calls);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task SceneUsesPreparedTargetAndConfirmsBothModeAndTemperature()
|
|
{
|
|
var gateway = new Gateway(ClimateState());
|
|
var module = Climate(gateway);
|
|
var config = Scene();
|
|
var context = new DeviceSceneContext(Runner(gateway), gateway.States, []);
|
|
await module.ExecuteSleepAsync(context, config, "balanced", default);
|
|
Assert.Equal(24, config.ClimateAdvice.TargetTemperatureCelsius);
|
|
Assert.Equal("heat", gateway.States[ClimateId].State);
|
|
Assert.Equal(24, gateway.States[ClimateId].GetDoubleAttribute("temperature"));
|
|
Assert.Equal(new[] { "set_hvac_mode", "set_temperature" }, gateway.Calls.Select(command => command.Service));
|
|
Assert.All(context.Results, result => Assert.Equal("Succeeded", result.Status));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task SceneDeterministicallyFitsPreparedTargetToActualIntersection()
|
|
{
|
|
var gateway = new Gateway(ClimateState(minimum: 24, maximum: 25));
|
|
var module = Climate(gateway);
|
|
var config = Scene(target: 22);
|
|
var context = new DeviceSceneContext(Runner(gateway), gateway.States, []);
|
|
await module.ExecuteSleepAsync(context, config, "balanced", default);
|
|
Assert.Equal(24, config.ClimateAdvice.TargetTemperatureCelsius);
|
|
await module.ExecuteSleepAsync(context, config, "balanced", default);
|
|
Assert.Equal(24, config.ClimateAdvice.TargetTemperatureCelsius);
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData("off")]
|
|
[InlineData("unavailable")]
|
|
public async Task ComfortableOrUnknownRoomTemperatureLeavesExistingClimateUntouched(string mode)
|
|
{
|
|
var gateway = new Gateway(ClimateState("cool"));
|
|
var module = Climate(gateway);
|
|
var context = new DeviceSceneContext(Runner(gateway), gateway.States, []);
|
|
await module.ExecuteSleepAsync(context, Scene(mode), "balanced", default);
|
|
Assert.Empty(gateway.Calls);
|
|
Assert.Equal("cool", gateway.States[ClimateId].State);
|
|
Assert.Equal("Skipped", Assert.Single(context.Results).Status);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task SceneNeverStartsWhenRecommendationAndDeviceHaveNoTemperatureIntersection()
|
|
{
|
|
var gateway = new Gateway(ClimateState(minimum: 30));
|
|
var module = Climate(gateway);
|
|
var context = new DeviceSceneContext(Runner(gateway), gateway.States, []);
|
|
await Assert.ThrowsAsync<InvalidOperationException>(() => module.ExecuteSleepAsync(context, Scene(), "balanced", default));
|
|
Assert.Empty(gateway.Calls);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task UnsupportedSuggestedModeIsFailureNotFakeSuccessfulScene()
|
|
{
|
|
var gateway = new Gateway(ClimateState());
|
|
var attributes = gateway.States[ClimateId].Attributes.EnumerateObject().ToDictionary(item => item.Name, item => (object?)item.Value.Clone());
|
|
attributes["hvac_modes"] = new[] { "cool", "off" };
|
|
gateway.States[ClimateId].Attributes = JsonSerializer.SerializeToElement(attributes);
|
|
var runner = Runner(gateway);
|
|
var coordinator = new HealthSceneCoordinator(new([Climate(gateway)]), runner, gateway, new FakeLight(), new FakeMusic());
|
|
var result = await coordinator.ExecuteAsync("sleep", Scene(), "balanced", default);
|
|
Assert.Equal("Failed", result.Status);
|
|
Assert.Contains(result.Actions, action => action.Status == "Failed" && action.Message.Contains("不支持"));
|
|
Assert.Empty(gateway.Calls);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task SceneStopsBeforeTemperatureWriteIfModeWasNotConfirmed()
|
|
{
|
|
var gateway = new Gateway(ClimateState()) { ApplyCommands = false };
|
|
var module = Climate(gateway);
|
|
var context = new DeviceSceneContext(Runner(gateway), gateway.States, []);
|
|
await module.ExecuteSleepAsync(context, Scene(), "balanced", default);
|
|
Assert.Equal("set_hvac_mode", Assert.Single(gateway.Calls).Service);
|
|
Assert.Equal("Failed", Assert.Single(context.Results).Status);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task AllOffClosesClimateButDoesNotMoveCurtain()
|
|
{
|
|
var gateway = new Gateway(ClimateState("heat"), CurtainState());
|
|
var runner = Runner(gateway);
|
|
var modules = new DeviceModuleRegistry([Climate(gateway), Curtain(gateway)]);
|
|
var coordinator = new HealthSceneCoordinator(modules, runner, gateway, new FakeLight(), new FakeMusic());
|
|
var output = await coordinator.ExecuteAsync("all-off", null, "", default);
|
|
Assert.Equal("Succeeded", output.Status);
|
|
Assert.Equal("off", gateway.States[ClimateId].State);
|
|
Assert.Equal(ClimateId, Assert.Single(gateway.Calls).EntityId);
|
|
Assert.Equal("turn_off", gateway.Calls[0].Service);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task UnsupportedPowerFeaturesRejectManualPowerCommands()
|
|
{
|
|
var gateway = new Gateway(ClimateState(features: 1));
|
|
var module = Climate(gateway);
|
|
await Assert.ThrowsAsync<InvalidOperationException>(() => module.SetPowerAsync(true, default));
|
|
await Assert.ThrowsAsync<InvalidOperationException>(() => module.SetPowerAsync(false, default));
|
|
Assert.Empty(gateway.Calls);
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData("open", "open_cover")]
|
|
[InlineData("stop", "stop_cover")]
|
|
[InlineData("close", "close_cover")]
|
|
public async Task CurtainSendsOneSupportedMotionCommandWithoutClaimingArrival(string action, string service)
|
|
{
|
|
var gateway = new Gateway(CurtainState());
|
|
var module = Curtain(gateway);
|
|
var command = module.BuildParameterCommand("curtain_action", action);
|
|
await module.ExecuteParameterAsync(command, default);
|
|
Assert.Null(command.ExpectedState);
|
|
Assert.Equal(service, Assert.Single(gateway.Calls).Service);
|
|
Assert.Equal(1, gateway.Reads);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task CurtainPositionIsIntegerPercentAndRequiresPositionFeature()
|
|
{
|
|
var gateway = new Gateway(CurtainState());
|
|
var module = Curtain(gateway);
|
|
await module.ExecuteParameterAsync(module.BuildParameterCommand("curtain_position", 0), default);
|
|
await module.ExecuteParameterAsync(module.BuildParameterCommand("curtain_position", 100), default);
|
|
Assert.Equal(new[] { 0, 100 }, gateway.Calls.Select(command => (int)command.Data!["position"]!));
|
|
Assert.Throws<InvalidOperationException>(() => module.BuildParameterCommand("curtain_position", 100.5));
|
|
gateway.States[CurtainId] = CurtainState(features: 11);
|
|
await Assert.ThrowsAsync<InvalidOperationException>(() => module.ExecuteParameterAsync(
|
|
module.BuildParameterCommand("curtain_position", 50), default));
|
|
Assert.Equal(2, gateway.Calls.Count);
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData("unavailable")]
|
|
[InlineData("unknown")]
|
|
public async Task OfflineCurtainRejectsCommandsAndCannotPretendOpen(string state)
|
|
{
|
|
var gateway = new Gateway(CurtainState(state));
|
|
var module = Curtain(gateway);
|
|
await Assert.ThrowsAsync<InvalidOperationException>(() => module.ExecuteParameterAsync(
|
|
module.BuildParameterCommand("curtain_action", "open"), default));
|
|
Assert.Empty(gateway.Calls);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task CurtainStopIsDisabledWhenDeviceOnlySupportsOpenAndClose()
|
|
{
|
|
var gateway = new Gateway(CurtainState(features: 3));
|
|
var module = Curtain(gateway);
|
|
await Assert.ThrowsAsync<InvalidOperationException>(() => module.ExecuteParameterAsync(
|
|
module.BuildParameterCommand("curtain_action", "stop"), default));
|
|
Assert.Empty(gateway.Calls);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task CurtainHasNoPowerAndNoAutomaticSceneMotion()
|
|
{
|
|
var gateway = new Gateway(CurtainState());
|
|
var module = Curtain(gateway);
|
|
await Assert.ThrowsAsync<InvalidOperationException>(() => module.SetPowerAsync(true, default));
|
|
var context = new DeviceSceneContext(Runner(gateway), gateway.States, []);
|
|
await module.ExecuteSleepAsync(context, Scene(), "balanced", default);
|
|
Assert.Equal("Skipped", Assert.Single(context.Results).Status);
|
|
Assert.Empty(module.GetShutdownCommands());
|
|
Assert.Empty(gateway.Calls);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task UnconfiguredModulesRemainSkippedAndAbsentFromFallbackDevices()
|
|
{
|
|
var gateway = new Gateway();
|
|
var runner = Runner(gateway);
|
|
var options = Options.Create(new HomeAssistantOptions());
|
|
var climate = new ClimateModule(runner, options);
|
|
var curtain = new CurtainModule(runner, options);
|
|
var context = new DeviceSceneContext(runner, gateway.States, []);
|
|
await climate.ExecuteSleepAsync(context, Scene(), "balanced", default);
|
|
await curtain.ExecuteSleepAsync(context, Scene(), "balanced", default);
|
|
Assert.All(context.Results, result => Assert.Equal("Skipped", result.Status));
|
|
Assert.Empty(climate.GetShutdownCommands());
|
|
Assert.Empty(new HomeAssistantDeviceCatalog(options, new([climate, curtain])).BuildDiscoveredDevices(null, gateway.States));
|
|
Assert.Empty(gateway.Calls);
|
|
}
|
|
|
|
[Fact]
|
|
public void RegistryBindsEachDeviceAndSafeAttributesExposeActualCapabilities()
|
|
{
|
|
var gateway = new Gateway(ClimateState(), CurtainState("unavailable"));
|
|
var options = Options.Create(Settings());
|
|
var catalog = new HomeAssistantDeviceCatalog(options, new([Climate(gateway), Curtain(gateway)]));
|
|
var registry = new HomeAssistantRegistries
|
|
{
|
|
Devices = [new() { Id = "ac", Name = "空调" }, new() { Id = "curtain", Name = "窗帘" }],
|
|
Entities = [new() { EntityId = ClimateId, DeviceId = "ac" }, new() { EntityId = CurtainId, DeviceId = "curtain" }]
|
|
};
|
|
var devices = catalog.BuildDiscoveredDevices(registry, gateway.States);
|
|
Assert.Equal("climate", devices[0].ModuleKey);
|
|
Assert.Equal(ClimateId, devices[0].PrimaryControlEntityId);
|
|
Assert.Equal("curtain", devices[1].ModuleKey);
|
|
Assert.Equal(CurtainId, devices[1].PrimaryControlEntityId);
|
|
Assert.False(devices[1].Online);
|
|
Assert.Contains("target_temp_step", devices[0].Entities[0].Attributes!.Keys);
|
|
Assert.Contains("current_temperature", devices[0].Entities[0].Attributes!.Keys);
|
|
Assert.Contains("fan_modes", devices[0].Entities[0].Attributes!.Keys);
|
|
Assert.Contains("current_position", devices[1].Entities[0].Attributes!.Keys);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task CancellationBeforeSendPreventsEveryNewDeviceWrite()
|
|
{
|
|
var gateway = new Gateway(ClimateState(), CurtainState());
|
|
using var cancellation = new CancellationTokenSource(); cancellation.Cancel();
|
|
var climate = Climate(gateway);
|
|
var curtain = Curtain(gateway);
|
|
await Assert.ThrowsAnyAsync<OperationCanceledException>(() => climate.ExecuteParameterAsync(
|
|
climate.BuildParameterCommand("climate_mode", "heat"), cancellation.Token));
|
|
await Assert.ThrowsAnyAsync<OperationCanceledException>(() => curtain.ExecuteParameterAsync(
|
|
curtain.BuildParameterCommand("curtain_action", "open"), cancellation.Token));
|
|
Assert.Empty(gateway.Calls);
|
|
}
|
|
|
|
private sealed class Gateway(params HomeAssistantState[] states) : IHomeAssistantGateway
|
|
{
|
|
public Dictionary<string, HomeAssistantState> States { get; } = states.ToDictionary(state => state.EntityId, StringComparer.OrdinalIgnoreCase);
|
|
public List<DeviceCommand> Calls { get; } = [];
|
|
public int Reads { get; private set; }
|
|
public bool ApplyCommands { get; set; } = true;
|
|
public bool BecomeUnavailableAfterSend { get; set; }
|
|
public string? ModeAfterTemperature { get; set; }
|
|
public Action<int>? BeforeRead { get; set; }
|
|
public Task<IReadOnlyList<HomeAssistantState>> GetStatesAsync(CancellationToken cancellationToken = default)
|
|
{
|
|
cancellationToken.ThrowIfCancellationRequested(); Reads++;
|
|
BeforeRead?.Invoke(Reads);
|
|
return Task.FromResult<IReadOnlyList<HomeAssistantState>>(States.Values.ToArray());
|
|
}
|
|
public Task CallServiceAsync(string domain, string service, IReadOnlyDictionary<string, object?> data,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
cancellationToken.ThrowIfCancellationRequested();
|
|
var entityId = (string)data["entity_id"]!;
|
|
Calls.Add(new(service, entityId, domain, service, new Dictionary<string, object?>(data)));
|
|
if (!ApplyCommands) return Task.CompletedTask;
|
|
var previous = States[entityId];
|
|
var attributes = previous.Attributes.EnumerateObject().ToDictionary(item => item.Name, item => (object?)item.Value.Clone());
|
|
var current = service switch
|
|
{
|
|
"set_hvac_mode" => (string)data["hvac_mode"]!,
|
|
"set_temperature" when ModeAfterTemperature is not null => ModeAfterTemperature,
|
|
"turn_on" => "heat",
|
|
"turn_off" => "off",
|
|
_ => previous.State
|
|
};
|
|
if (data.TryGetValue("temperature", out var temperature)) attributes["temperature"] = temperature;
|
|
if (data.TryGetValue("fan_mode", out var fan)) attributes["fan_mode"] = fan;
|
|
States[entityId] = new() { EntityId = entityId, State = BecomeUnavailableAfterSend ? "unavailable" : current,
|
|
Attributes = JsonSerializer.SerializeToElement(attributes) };
|
|
return Task.CompletedTask;
|
|
}
|
|
}
|
|
}
|