377 lines
20 KiB
C#
377 lines
20 KiB
C#
using Evergreen.Health.Application.Health.Devices;
|
|
using Evergreen.Health.Application.Health.Devices.Audio;
|
|
using Evergreen.Health.Application.Health.Devices.Contracts;
|
|
using Evergreen.Health.Application.Health.Devices.Discovery;
|
|
using Evergreen.Health.Application.Health.Devices.Environment;
|
|
using Evergreen.Health.Application.Health.Devices.Lighting;
|
|
using Evergreen.Health.Application.Health.Integrations.HomeAssistant;
|
|
using Evergreen.Health.Application.Health.Integrations.MiAirX;
|
|
using Evergreen.Health.Application.Health.Models;
|
|
using Evergreen.Health.Application.Health.Policies;
|
|
using Evergreen.Health.Application.Health.Scenes;
|
|
using Microsoft.Extensions.DependencyInjection;
|
|
using Microsoft.Extensions.Hosting;
|
|
using Microsoft.Extensions.Logging.Abstractions;
|
|
using Microsoft.Extensions.Options;
|
|
using System.Text.Json;
|
|
using Xunit;
|
|
|
|
namespace Admin.NET.Test.Health;
|
|
|
|
public sealed class DeviceArchitectureTests
|
|
{
|
|
private static DeviceActionRunner Runner(FakeHaGateway gateway)
|
|
=> new(gateway, TimeProvider.System, new DeviceExecutionOptions { VerificationDelay = TimeSpan.Zero });
|
|
private static HomeAssistantOptions Configuration() => new()
|
|
{
|
|
Entities = new()
|
|
{
|
|
AmbientLight = "light.bedroom", Speaker = "media_player.speaker", SpeakerSleepMode = "switch.sleep",
|
|
Temperature = "sensor.room_temperature"
|
|
}
|
|
};
|
|
private static HomeAssistantState Temperature(double value, DateTimeOffset? observed) => new()
|
|
{
|
|
EntityId = "sensor.room_temperature", State = value.ToString(System.Globalization.CultureInfo.InvariantCulture),
|
|
LastReported = observed, Attributes = JsonSerializer.SerializeToElement(new { unit_of_measurement = "°C" })
|
|
};
|
|
|
|
[Theory]
|
|
[InlineData(21.9, "heat")]
|
|
[InlineData(22.0, "off")]
|
|
[InlineData(23.5, "off")]
|
|
[InlineData(25.0, "off")]
|
|
[InlineData(25.1, "cool")]
|
|
public void RoomRangeIncludesBothBoundaries(double temperature, string expected)
|
|
{
|
|
var advice = RoomTemperaturePolicy.Evaluate(new(temperature, DateTimeOffset.UtcNow, true, ""), new(22, 25));
|
|
Assert.Equal(expected, advice.Mode);
|
|
Assert.Null(advice.TargetTemperatureCelsius);
|
|
RoomTemperaturePolicy.PickTargetForSession(advice);
|
|
if (expected is "heat" or "cool")
|
|
Assert.InRange(advice.TargetTemperatureCelsius!.Value, 22, 25);
|
|
else Assert.Null(advice.TargetTemperatureCelsius);
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData(0)]
|
|
[InlineData(31)]
|
|
public void MissingOrStaleIndoorTimestampIsNotUsable(int ageMinutes)
|
|
{
|
|
var reader = new IndoorEnvironmentReader(Options.Create(Configuration()), TimeProvider.System);
|
|
var state = Temperature(10, ageMinutes == 0 ? null : DateTimeOffset.UtcNow.AddMinutes(-ageMinutes));
|
|
var sample = reader.Read(new Dictionary<string, HomeAssistantState> { [state.EntityId] = state });
|
|
Assert.False(sample.Available);
|
|
Assert.Equal("unavailable", RoomTemperaturePolicy.Evaluate(sample, new(22, 25)).Mode);
|
|
}
|
|
|
|
[Fact]
|
|
public void FreshIndoorDataAndConstitutionRangeAreUsed()
|
|
{
|
|
var reader = new IndoorEnvironmentReader(Options.Create(Configuration()), TimeProvider.System);
|
|
var state = Temperature(23, DateTimeOffset.UtcNow);
|
|
var sample = reader.Read(new Dictionary<string, HomeAssistantState> { [state.EntityId] = state });
|
|
Assert.True(sample.Available);
|
|
Assert.Equal("heat", RoomTemperaturePolicy.Evaluate(sample, RecommendedRoomRange.ForConstitution("yang_deficient")).Mode);
|
|
Assert.Equal("off", RoomTemperaturePolicy.Evaluate(sample, RecommendedRoomRange.ForConstitution("balanced")).Mode);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task NewDeviceWorksThroughRegistryAndSceneWithoutCoreBranches()
|
|
{
|
|
var gateway = new FakeHaGateway();
|
|
gateway.States["number.synthetic"] = new() { EntityId = "number.synthetic", State = "0" };
|
|
var runner = Runner(gateway);
|
|
var module = new SyntheticDeviceModule(runner);
|
|
var registry = new DeviceModuleRegistry([module]);
|
|
Assert.Same(module, registry.ForParameter("synthetic_value"));
|
|
await registry.ForParameter("synthetic_value").ExecuteParameterAsync(module.BuildParameterCommand("synthetic_value", 4), default);
|
|
var coordinator = new HealthSceneCoordinator(registry, runner, gateway, new FakeLight(), new FakeMusic());
|
|
var output = await coordinator.ExecuteAsync("sleep", new(), "balanced", default);
|
|
Assert.Equal("Succeeded", output.Status);
|
|
Assert.Contains(gateway.Calls, command => command.EntityId == "number.synthetic" && (int)command.Data!["value"]! == 3);
|
|
}
|
|
|
|
[Fact]
|
|
public void DuplicateParameterRegistrationsFailEarly()
|
|
{
|
|
var runner = Runner(new());
|
|
Assert.Throws<InvalidOperationException>(() => new DeviceModuleRegistry(
|
|
[new SyntheticDeviceModule(runner), new SyntheticDeviceModule(runner, key: "other")]));
|
|
}
|
|
|
|
[Fact]
|
|
public void DeviceParametersValidateRangesAndTrustedEntity()
|
|
{
|
|
var runner = Runner(new());
|
|
var light = new LightingModule(runner, Options.Create(Configuration()), new FakeLight(), new LightOffDispatcher([]));
|
|
Assert.Throws<InvalidOperationException>(() => light.BuildParameterCommand("color_temp", 6500));
|
|
Assert.Equal("light.bedroom", light.BuildParameterCommand("brightness", 0).EntityId);
|
|
Assert.Equal("turn_off", light.BuildParameterCommand("brightness", 0).Service);
|
|
}
|
|
|
|
[Fact]
|
|
public void DiscoveryAcceptsOtherBrandsAndFiltersDisabledEntities()
|
|
{
|
|
var gateway = new FakeHaGateway();
|
|
var registry = new DeviceModuleRegistry([]);
|
|
var catalog = new HomeAssistantDeviceCatalog(Options.Create(Configuration()), registry);
|
|
var haRegistry = new HomeAssistantRegistries
|
|
{
|
|
Devices = [new() { Id = "tuya-device", Name = "窗帘", Manufacturer = "Tuya" }],
|
|
Entities =
|
|
[
|
|
new() { DeviceId = "tuya-device", EntityId = "cover.curtain", Platform = "tuya" },
|
|
new() { DeviceId = "tuya-device", EntityId = "switch.disabled", DisabledBy = "user" }
|
|
]
|
|
};
|
|
var devices = catalog.BuildDiscoveredDevices(haRegistry, new Dictionary<string, HomeAssistantState>
|
|
{
|
|
["cover.curtain"] = new() { EntityId = "cover.curtain", State = "open" }
|
|
});
|
|
Assert.Single(devices);
|
|
Assert.Equal("Curtain", devices[0].Category);
|
|
Assert.True(devices[0].Online);
|
|
Assert.Single(devices[0].Entities);
|
|
}
|
|
|
|
[Fact]
|
|
public void FallbackOnlyContainsConfiguredModules()
|
|
{
|
|
var runner = Runner(new());
|
|
var options = Options.Create(Configuration());
|
|
var catalog = new HomeAssistantDeviceCatalog(options, new DeviceModuleRegistry([new LightingModule(runner, options, new FakeLight(), new LightOffDispatcher([]))]));
|
|
var device = Assert.Single(catalog.BuildDiscoveredDevices(null, new Dictionary<string, HomeAssistantState>()));
|
|
Assert.Equal("lighting", device.ModuleKey);
|
|
Assert.Contains(device.Entities, entity => entity.EntityId == "light.bedroom");
|
|
Assert.Equal("light.bedroom", device.PrimaryControlEntityId);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Configured_inventory_filters_retired_devices_even_if_ha_cache_still_reports_them()
|
|
{
|
|
var services = new ServiceCollection();
|
|
services.AddLogging(); services.AddOptions(); services.AddHttpClient(); services.AddHealthDevices();
|
|
var gateway = new FakeHaGateway { ReadsToFail = 1 };
|
|
services.AddSingleton<IHomeAssistantGateway>(gateway);
|
|
services.AddSingleton(Options.Create(new HomeAssistantOptions
|
|
{
|
|
OnlyConfiguredDevices = true,
|
|
Entities = new()
|
|
{
|
|
Climate = "climate.keep", Temperature = "sensor.keep_temperature", Curtain = "cover.keep",
|
|
Dehumidifier = "humidifier.keep", FragranceSwitch = "switch.keep_fragrance", Speaker = "media_player.keep"
|
|
},
|
|
DisplayOnlyDevices = [new() { EntityId = "switch.wake_fragrance", Name = "香氛机 唤醒", Category = "Fragrance" }]
|
|
}));
|
|
using var provider = services.BuildServiceProvider();
|
|
var entities = new[] { "climate.keep", "sensor.keep_temperature", "cover.keep", "humidifier.keep",
|
|
"switch.keep_fragrance", "media_player.keep", "switch.wake_fragrance", "light.removed", "switch.removed_outlet", "sensor.removed_weight" };
|
|
var registry = new HomeAssistantRegistries
|
|
{
|
|
Devices = entities.Select(id => new HomeAssistantDeviceRegistryItem { Id = id, Name = id }).ToList(),
|
|
Entities = entities.Select(id => new HomeAssistantEntityRegistryItem { DeviceId = id, EntityId = id }).ToList()
|
|
};
|
|
var state = entities.ToDictionary(id => id, id => new HomeAssistantState { EntityId = id, State = "on" });
|
|
var catalog = provider.GetRequiredService<HomeAssistantDeviceCatalog>();
|
|
foreach (var inventory in new[] { catalog.BuildDiscoveredDevices(registry, state), catalog.BuildDiscoveredDevices(null, state) })
|
|
{
|
|
Assert.Equal(7, inventory.Count);
|
|
Assert.DoesNotContain(inventory.SelectMany(device => device.Entities), entity => entity.EntityId.Contains("removed"));
|
|
var wake = Assert.Single(inventory, device => device.Entities.Any(entity => entity.EntityId == "switch.wake_fragrance"));
|
|
Assert.Null(wake.PrimaryControlEntityId);
|
|
Assert.True(wake.Online);
|
|
var sleep = Assert.Single(inventory, device => device.Entities.Any(entity => entity.EntityId == "switch.keep_fragrance"));
|
|
Assert.Equal("switch.keep_fragrance", sleep.PrimaryControlEntityId);
|
|
}
|
|
var modules = provider.GetRequiredService<DeviceModuleRegistry>();
|
|
Assert.DoesNotContain(modules.Modules, module => module.Key is "lighting" or "outlet");
|
|
Assert.Throws<InvalidOperationException>(() => modules.ForParameter("brightness"));
|
|
var lights = provider.GetRequiredService<HomeAssistantLightTransitionService>();
|
|
await Assert.ThrowsAsync<InvalidOperationException>(() => lights.StartSleepAsync(2700, default));
|
|
Assert.Equal(1, gateway.ReadsToFail);
|
|
Assert.Empty(gateway.Calls);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task FragranceManualControlsUseTheirOwnDeviceAndKeepWakeOutOfSleepScenes()
|
|
{
|
|
var services = new ServiceCollection();
|
|
services.AddLogging(); services.AddOptions(); services.AddHttpClient(); services.AddHealthDevices();
|
|
var gateway = new FakeHaGateway();
|
|
services.AddSingleton<IHomeAssistantGateway>(gateway);
|
|
services.AddSingleton(Options.Create(new HomeAssistantOptions
|
|
{
|
|
OnlyConfiguredDevices = true,
|
|
Entities = new()
|
|
{
|
|
FragranceSwitch = "switch.sleep_fragrance", FragranceDuration = "number.sleep_duration",
|
|
FragranceQuickRelease = "button.sleep_release",
|
|
WakeFragranceSwitch = "switch.wake_fragrance", WakeFragranceDuration = "number.wake_duration",
|
|
WakeFragranceQuickRelease = "button.wake_release"
|
|
}
|
|
}));
|
|
using var provider = services.BuildServiceProvider();
|
|
var registry = provider.GetRequiredService<DeviceModuleRegistry>();
|
|
var catalog = provider.GetRequiredService<HomeAssistantDeviceCatalog>();
|
|
var parameters = provider.GetRequiredService<DeviceParameterController>();
|
|
var sleep = Assert.Single(registry.Modules, module => module.Key == "fragrance");
|
|
var wake = Assert.Single(registry.Modules, module => module.Key == "wake-fragrance");
|
|
foreach (var id in sleep.EntityIds.Concat(wake.EntityIds))
|
|
gateway.States[id] = new() { EntityId = id, State = "off" };
|
|
var inventory = catalog.BuildDiscoveredDevices(null, gateway.States);
|
|
var sleepDevice = Assert.Single(inventory, device => device.ModuleKey == sleep.Key);
|
|
var wakeDevice = Assert.Single(inventory, device => device.ModuleKey == wake.Key);
|
|
Assert.Equal("switch.wake_fragrance", wakeDevice.PrimaryControlEntityId);
|
|
await wake.SetPowerAsync(true, default);
|
|
await parameters.ExecuteAsync(wakeDevice, gateway.States, "wake_fragrance_duration", 5, null, default);
|
|
await parameters.ExecuteAsync(wakeDevice, gateway.States, "wake_fragrance_quick_release", true, null, default);
|
|
await parameters.ExecuteAsync(sleepDevice, gateway.States, "fragrance_duration", 3, null, default);
|
|
await Assert.ThrowsAsync<InvalidOperationException>(() => parameters.ExecuteAsync(
|
|
wakeDevice, gateway.States, "fragrance_quick_release", true, null, default));
|
|
await Assert.ThrowsAsync<InvalidOperationException>(() => parameters.ExecuteAsync(
|
|
sleepDevice, gateway.States, "wake_fragrance_quick_release", true, null, default));
|
|
Assert.Equal(new[] { "switch.wake_fragrance", "number.wake_duration", "button.wake_release", "number.sleep_duration" },
|
|
gateway.Calls.Select(command => command.EntityId));
|
|
Assert.Null(wake.BuildPreview(new(), "balanced"));
|
|
Assert.Empty(wake.GetShutdownCommands());
|
|
var context = new DeviceSceneContext(Runner(gateway), gateway.States, []);
|
|
await wake.ExecuteSleepAsync(context, new() { EnableFragrance = true }, "balanced", default);
|
|
Assert.Equal(4, gateway.Calls.Count);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ShutdownContinuesAfterAnIndividualDeviceFailure()
|
|
{
|
|
var gateway = new FakeHaGateway { FailEntity = "switch.bad" };
|
|
var runner = Runner(gateway);
|
|
var coordinator = new HealthSceneCoordinator(new([new SyntheticDeviceModule(runner, twoOff: true)]),
|
|
runner, gateway, new FakeLight(), new FakeMusic());
|
|
var output = await coordinator.ExecuteAsync("all-off", null, "", default);
|
|
Assert.Equal("Partial", output.Status);
|
|
Assert.Contains(output.Actions, action => action.EntityId == "switch.bad" && action.Status == "Failed");
|
|
Assert.Contains(output.Actions, action => action.EntityId == "switch.synthetic" && action.Status == "Succeeded");
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ShutdownStillSendsWhenInitialStateReadFails()
|
|
{
|
|
var gateway = new FakeHaGateway { ReadsToFail = 1 };
|
|
var runner = Runner(gateway);
|
|
var music = new FakeMusic();
|
|
var coordinator = new HealthSceneCoordinator(new([new SyntheticDeviceModule(runner)]),
|
|
runner, gateway, new FakeLight(), music);
|
|
var output = await coordinator.ExecuteAsync("all-off", null, "", default);
|
|
Assert.True(music.StopCount > 0);
|
|
Assert.Contains(gateway.Calls, command => command.EntityId == "switch.synthetic" && command.Service == "turn_off");
|
|
Assert.Contains(output.Actions, action => action.EntityId == "switch.synthetic" && action.Status == "Succeeded");
|
|
}
|
|
|
|
[Fact]
|
|
public async Task AllOffCancelsOldStartBeforeItCanSendDelayedCommands()
|
|
{
|
|
var gateway = new FakeHaGateway();
|
|
var runner = Runner(gateway);
|
|
var module = new SyntheticDeviceModule(runner, block: true);
|
|
var coordinator = new HealthSceneCoordinator(new([module]), runner, gateway, new FakeLight(), new FakeMusic());
|
|
var start = coordinator.ExecuteAsync("sleep", new(), "balanced", default);
|
|
await module.Started.Task.WaitAsync(TimeSpan.FromSeconds(3));
|
|
var off = await coordinator.ExecuteAsync("all-off", null, "", default);
|
|
Assert.Equal("Cancelled", (await start).Status);
|
|
Assert.False(module.ContinuedAfterWait);
|
|
Assert.Equal("Succeeded", off.Status);
|
|
Assert.DoesNotContain(gateway.Calls, command => command.Service == "set_value");
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ManualLightOffOnlyControlsTheLight()
|
|
{
|
|
var gateway = new FakeHaGateway();
|
|
var runner = Runner(gateway);
|
|
var options = Options.Create(Configuration());
|
|
var light = new LightingModule(runner, options, new FakeLight(), new LightOffDispatcher([]));
|
|
await light.ExecuteParameterAsync(light.BuildParameterCommand("brightness", 0), default);
|
|
Assert.Equal("off", gateway.States["light.bedroom"].State);
|
|
Assert.All(gateway.Calls, command => Assert.Equal("light.bedroom", command.EntityId));
|
|
}
|
|
|
|
[Fact]
|
|
public void UnknownButtonIsUsableButUnknownSensorIsNot()
|
|
{
|
|
Assert.True(DeviceActionRunner.IsAvailable(new() { EntityId = "button.release", State = "unknown" }));
|
|
Assert.False(DeviceActionRunner.IsAvailable(new() { EntityId = "sensor.temperature", State = "unknown" }));
|
|
Assert.Null(new HomeAssistantState().GetDoubleAttribute("brightness"));
|
|
}
|
|
|
|
[Fact]
|
|
public void ModuleRegistrationResolvesWithoutCircularOrScopedDependencies()
|
|
{
|
|
var services = new ServiceCollection();
|
|
services.AddLogging(); services.AddOptions(); services.AddHttpClient();
|
|
services.AddHealthDevices();
|
|
using var provider = services.BuildServiceProvider(new ServiceProviderOptions { ValidateOnBuild = true, ValidateScopes = true });
|
|
Assert.NotNull(provider.GetRequiredService<HealthSceneCoordinator>());
|
|
Assert.Equal(4, provider.GetServices<IHostedService>().Count());
|
|
Assert.DoesNotContain(provider.GetRequiredService<DeviceModuleRegistry>().Modules, module => module.Key == "mattress");
|
|
Assert.Empty(provider.GetServices<ILightOffHandler>());
|
|
Assert.Throws<InvalidOperationException>(() => provider.GetRequiredService<DeviceModuleRegistry>().ForParameter("mattress_left_heating"));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ShortPlaylistInheritsFadeVolumeAndStopsAtZero()
|
|
{
|
|
var time = new ManualTestTimeProvider();
|
|
var audio = new FakeAudioGateway();
|
|
var options = Options.Create(new TcmMusicOptions
|
|
{
|
|
StartVolume = 70, FadeStepSeconds = 1, // 本版固定规则不能被历史设置覆盖。
|
|
Tracks = new() { ["balanced"] =
|
|
[
|
|
new() { Title = "A", Url = "https://example.invalid/a.mp3", DurationSeconds = 20 },
|
|
new() { Title = "B", Url = "https://example.invalid/b.mp3", DurationSeconds = 20 }
|
|
] }
|
|
});
|
|
var music = new TcmMusicTransitionService(audio, options, NullLogger<TcmMusicTransitionService>.Instance, time);
|
|
await music.StartForConstitutionAsync("balanced", default);
|
|
Assert.Equal(50, music.GetStatus().VolumePercent);
|
|
for (var step = 1; step <= 5; step++)
|
|
{
|
|
time.Advance(TimeSpan.FromSeconds(5));
|
|
var expected = 50 - step;
|
|
await WaitUntilAsync(() => music.GetStatus().VolumePercent == expected);
|
|
}
|
|
Assert.Equal(2, audio.Tracks.Count);
|
|
Assert.Equal(new[] { 50, 49, 48, 47, 46, 45 }, audio.Volumes.ToArray());
|
|
time.Advance(TimeSpan.FromSeconds(225));
|
|
await WaitUntilAsync(() => !music.GetStatus().Running);
|
|
Assert.Equal(0, music.GetStatus().VolumePercent);
|
|
Assert.True(audio.Stops > 0);
|
|
await music.StopMusicAsync(default);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ManualVolumeTakesOwnershipAndEveryRestartBeginsAtFifty()
|
|
{
|
|
var time = new ManualTestTimeProvider();
|
|
var audio = new FakeAudioGateway();
|
|
var music = new TcmMusicTransitionService(audio, Options.Create(new TcmMusicOptions
|
|
{
|
|
Tracks = new() { ["balanced"] = [new() { Url = "https://example.invalid/a.mp3", DurationSeconds = 20 }] }
|
|
}), NullLogger<TcmMusicTransitionService>.Instance, time);
|
|
await music.StartForConstitutionAsync("balanced", default);
|
|
await music.SetManualVolumeAsync(30, default);
|
|
time.Advance(TimeSpan.FromSeconds(50));
|
|
Assert.False(music.GetStatus().Running);
|
|
Assert.Equal(30, music.GetStatus().VolumePercent);
|
|
await music.StartForConstitutionAsync("balanced", default);
|
|
Assert.Equal(50, music.GetStatus().VolumePercent);
|
|
await music.StopMusicAsync(default);
|
|
}
|
|
|
|
private static async Task WaitUntilAsync(Func<bool> predicate)
|
|
{
|
|
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(3));
|
|
while (!predicate()) await Task.Delay(5, timeout.Token);
|
|
}
|
|
}
|