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 { [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 { [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(() => 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(() => 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 { ["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())); 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(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(); 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(); Assert.DoesNotContain(modules.Modules, module => module.Key is "lighting" or "outlet"); Assert.Throws(() => modules.ForParameter("brightness")); var lights = provider.GetRequiredService(); await Assert.ThrowsAsync(() => 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(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(); var catalog = provider.GetRequiredService(); var parameters = provider.GetRequiredService(); 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(() => parameters.ExecuteAsync( wakeDevice, gateway.States, "fragrance_quick_release", true, null, default)); await Assert.ThrowsAsync(() => 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()); Assert.Equal(4, provider.GetServices().Count()); Assert.DoesNotContain(provider.GetRequiredService().Modules, module => module.Key == "mattress"); Assert.Empty(provider.GetServices()); Assert.Throws(() => provider.GetRequiredService().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.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.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 predicate) { using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(3)); while (!predicate()) await Task.Delay(5, timeout.Token); } }