11
This commit is contained in:
@@ -37,11 +37,14 @@ internal sealed class FakeHaGateway : IHomeAssistantGateway
|
||||
"select_option" => data["option"]!.ToString()!,
|
||||
"set_value" => data["value"]!.ToString()!, _ => States.GetValueOrDefault(entity)?.State ?? "unknown"
|
||||
};
|
||||
if (service == "set_hvac_mode") state = (string)data["hvac_mode"]!;
|
||||
var attributes = new Dictionary<string, object?>();
|
||||
if (States.TryGetValue(entity, out var previous) && previous.Attributes.ValueKind == JsonValueKind.Object)
|
||||
foreach (var property in previous.Attributes.EnumerateObject()) attributes[property.Name] = property.Value.Clone();
|
||||
if (data.TryGetValue("brightness_pct", out var brightness))
|
||||
attributes["brightness"] = Convert.ToDouble(brightness) / 100 * 255;
|
||||
foreach (var key in new[] { "effect", "color_temp_kelvin", "temperature", "fan_mode" })
|
||||
if (data.TryGetValue(key, out var value)) attributes[key] = value;
|
||||
States[entity] = new() { EntityId = entity, State = state, LastReported = Time.GetUtcNow(), LastUpdated = Time.GetUtcNow(),
|
||||
Attributes = JsonSerializer.SerializeToElement(attributes) };
|
||||
return Task.CompletedTask;
|
||||
|
||||
@@ -28,6 +28,8 @@ public sealed class HomeAssistantAttributeSerializationTests
|
||||
fan_modes = new[] { "自动", "低风", "中风", "高风" },
|
||||
swing_modes = new[] { "off", "vertical" },
|
||||
options = new[] { "智能适配", "轻柔", "全身" },
|
||||
supported_color_modes = new[] { "color_temp" },
|
||||
effect_list = new[] { "阅读模式", "助眠模式" },
|
||||
current_temperature = 24.9,
|
||||
temperature = 23,
|
||||
temperature_unit = "°C",
|
||||
@@ -50,6 +52,8 @@ public sealed class HomeAssistantAttributeSerializationTests
|
||||
[InlineData("fan_modes")]
|
||||
[InlineData("swing_modes")]
|
||||
[InlineData("options")]
|
||||
[InlineData("supported_color_modes")]
|
||||
[InlineData("effect_list")]
|
||||
public void ApiSerializesChoiceArraysAsStringsNotJsonElementMetadata(string key)
|
||||
{
|
||||
var state = State();
|
||||
|
||||
@@ -0,0 +1,315 @@
|
||||
using Evergreen.Health.Application.Health.Devices;
|
||||
using Evergreen.Health.Application.Health.Devices.Climate;
|
||||
using Evergreen.Health.Application.Health.Devices.Contracts;
|
||||
using Evergreen.Health.Application.Health.Devices.Discovery;
|
||||
using Evergreen.Health.Application.Health.Devices.Environment;
|
||||
using Evergreen.Health.Application.Health.Devices.Lighting;
|
||||
using Evergreen.Health.Application.Health.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 Evergreen.Health.Application.Health.Services;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using System.Text.Json;
|
||||
using Xunit;
|
||||
|
||||
namespace Admin.NET.Test.Health;
|
||||
|
||||
/// <summary>两盏灯和空调的真实模块组合,全部使用内存网关与可推进时钟,绝不控制实机。</summary>
|
||||
public sealed class SceneLightingIntegrationTests
|
||||
{
|
||||
private const string Primary = "light.bedroom", Strip = "light.strip", Climate = "climate.room", Temperature = "sensor.indoor";
|
||||
private static SceneLightOptions StripSettings() => new() { Key = "sleep-light-strip", Name = "测试助眠灯带", EntityId = Strip, SleepEffect = "助眠模式" };
|
||||
private static HomeAssistantOptions Settings() => new()
|
||||
{
|
||||
Entities = new() { AmbientLight = Primary, Climate = Climate, Temperature = Temperature,
|
||||
MattressLeftHeatingLevel = "number.left_heat", MattressRightHeatingLevel = "number.right_heat" },
|
||||
AdditionalSleepLights = [StripSettings()], SleepScene = new() { EnableMattress = true }
|
||||
};
|
||||
private static HomeAssistantState Light(string id, string state = "off", string[]? effects = null) => new()
|
||||
{
|
||||
EntityId = id, State = state, Attributes = JsonSerializer.SerializeToElement(new
|
||||
{
|
||||
supported_color_modes = new[] { "color_temp" }, min_color_temp_kelvin = 2700, max_color_temp_kelvin = 6500,
|
||||
supported_features = 4, effect_list = effects ?? ["阅读模式", "助眠模式"], brightness = 128, color_temp_kelvin = 3000
|
||||
})
|
||||
};
|
||||
private static HomeAssistantState ClimateState(string mode = "off") => new()
|
||||
{
|
||||
EntityId = Climate, State = mode, Attributes = JsonSerializer.SerializeToElement(new
|
||||
{
|
||||
hvac_modes = new[] { "heat", "cool", "off", "fan_only" }, supported_features = 393,
|
||||
min_temp = 5, max_temp = 35, target_temp_step = .5, temperature_unit = "°C", temperature = 23,
|
||||
current_temperature = 99 // 故意与环境仪不同,不能误用空调内置温度进行舒睡判断。
|
||||
})
|
||||
};
|
||||
private sealed class Handler : ILightOffHandler
|
||||
{
|
||||
public int Calls;
|
||||
public Task HandleAsync(CancellationToken token) { Calls++; return Task.CompletedTask; }
|
||||
}
|
||||
private sealed class Fixture
|
||||
{
|
||||
public ManualTestTimeProvider Time { get; } = new();
|
||||
public FakeHaGateway Gateway { get; }
|
||||
public IOptions<HomeAssistantOptions> Options { get; } = Microsoft.Extensions.Options.Options.Create(Settings());
|
||||
public Handler Off { get; } = new();
|
||||
public HomeAssistantLightTransitionService Lights { get; }
|
||||
public DeviceActionRunner Runner { get; }
|
||||
public DeviceModuleRegistry Modules { get; }
|
||||
public HealthSceneCoordinator Coordinator { get; }
|
||||
public Fixture(bool mattress = false)
|
||||
{
|
||||
Gateway = new() { Time = Time };
|
||||
Gateway.States[Primary] = Light(Primary); Gateway.States[Strip] = Light(Strip);
|
||||
Gateway.States[Climate] = ClimateState();
|
||||
foreach (var id in new[] { "number.left_heat", "number.right_heat" }) Gateway.States[id] = new() { EntityId = id, State = "0" };
|
||||
Runner = new(Gateway, Time, new() { VerificationDelay = TimeSpan.Zero });
|
||||
var dispatcher = new LightOffDispatcher([Off]);
|
||||
Lights = new(Gateway, dispatcher, Options, NullLogger<HomeAssistantLightTransitionService>.Instance, new(), Time);
|
||||
var modules = new List<IHealthDeviceModule>
|
||||
{
|
||||
new LightingModule(Runner, Options, Lights.ForEntity(Primary), dispatcher),
|
||||
new ConfiguredSleepLightModule(Runner, StripSettings(), Lights.ForEntity(Strip)),
|
||||
new ClimateModule(Runner, Options)
|
||||
};
|
||||
if (mattress) modules.Add(new MattressModule(Runner, Options));
|
||||
Modules = new(modules);
|
||||
Coordinator = new(Modules, Runner, Gateway, Lights, new FakeMusic());
|
||||
}
|
||||
public PreparedSleepScene Prepare(double? temperature)
|
||||
{
|
||||
if (temperature is not null) Gateway.States[Temperature] = new()
|
||||
{
|
||||
EntityId = Temperature, State = temperature.Value.ToString(System.Globalization.CultureInfo.InvariantCulture),
|
||||
LastReported = Time.GetUtcNow(), Attributes = JsonSerializer.SerializeToElement(new { unit_of_measurement = "°C" })
|
||||
};
|
||||
else Gateway.States.TryRemove(Temperature, out _);
|
||||
var comfort = new MattressComfortService(Gateway, new IndoorEnvironmentReader(Options, Time), Options)
|
||||
.Get(null, Gateway.States);
|
||||
var config = new PersonalizedSceneConfig { LightColorTemperatureKelvin = 3000 };
|
||||
MattressComfortService.ApplyTo(config, comfort);
|
||||
RoomTemperaturePolicy.PickTargetForSession(config.ClimateAdvice);
|
||||
return new(config, "balanced");
|
||||
}
|
||||
}
|
||||
private static async Task WaitUntilAsync(Func<bool> predicate)
|
||||
{
|
||||
for (var attempt = 0; attempt < 1000 && !predicate(); attempt++) await Task.Delay(1);
|
||||
Assert.True(predicate(), "模拟渐变任务未按时推进");
|
||||
}
|
||||
private static async Task<T> CompleteAsync<T>(Task<T> task, ManualTestTimeProvider time)
|
||||
{
|
||||
for (var attempt = 0; attempt < 1000 && !task.IsCompleted; attempt++)
|
||||
{
|
||||
time.Advance(TimeSpan.FromMilliseconds(100)); await Task.Delay(1);
|
||||
}
|
||||
Assert.True(task.IsCompleted, "场景未在模拟时间中完成");
|
||||
return await task;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SceneStartsBothLightsAtFiftyAndSetsOnlyStripsOwnSleepEffect()
|
||||
{
|
||||
var f = new Fixture();
|
||||
try
|
||||
{
|
||||
var result = await f.Coordinator.ExecuteAsync("sleep", f.Prepare(24).Config, "balanced", default);
|
||||
Assert.True(result.Success);
|
||||
Assert.Equal(new[] { Primary, Strip }, f.Gateway.Calls.Where(call => call.Domain == "light").Select(call => call.EntityId));
|
||||
var strip = f.Gateway.Calls.Single(call => call.EntityId == Strip);
|
||||
Assert.Equal("助眠模式", strip.Data!["effect"]); Assert.Equal(50, strip.Data["brightness_pct"]);
|
||||
Assert.Equal(3000, strip.Data["color_temp_kelvin"]);
|
||||
Assert.False(f.Gateway.Calls.Single(call => call.EntityId == Primary).Data!.ContainsKey("effect"));
|
||||
f.Time.Advance(TimeSpan.FromSeconds(5));
|
||||
await WaitUntilAsync(() => f.Lights.GetStatus().Lights.All(light => light.BrightnessPercent == 49));
|
||||
Assert.Equal(2, f.Lights.GetStatus().Lights.Count);
|
||||
Assert.All(f.Lights.GetStatus().Lights, light => Assert.True(light.Running));
|
||||
}
|
||||
finally { await f.Lights.StopAsync(default); }
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task BothLightsReachZeroAndOnlyOriginalLightKeepsMassageShutdownHook()
|
||||
{
|
||||
var f = new Fixture();
|
||||
try
|
||||
{
|
||||
await f.Coordinator.ExecuteAsync("sleep", f.Prepare(24).Config, "balanced", default);
|
||||
for (var step = 1; step <= 50; step++)
|
||||
{
|
||||
f.Time.Advance(TimeSpan.FromSeconds(5));
|
||||
var expected = 50 - step;
|
||||
await WaitUntilAsync(() => f.Lights.GetStatus().Lights.All(light => light.BrightnessPercent == expected &&
|
||||
(expected != 0 || !light.Running)));
|
||||
}
|
||||
Assert.Equal("off", f.Gateway.States[Primary].State); Assert.Equal("off", f.Gateway.States[Strip].State);
|
||||
Assert.Equal(1, f.Off.Calls);
|
||||
}
|
||||
finally { await f.Lights.StopAsync(default); }
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AllOffCancelsBothTimersAndShutsClimateWithoutLateReopening()
|
||||
{
|
||||
var f = new Fixture();
|
||||
try
|
||||
{
|
||||
await f.Coordinator.ExecuteAsync("sleep", f.Prepare(30).Config, "balanced", default);
|
||||
var result = await f.Coordinator.ExecuteAsync("all-off", null, "", default);
|
||||
Assert.True(result.Success);
|
||||
Assert.All(new[] { Primary, Strip, Climate }, id => Assert.Equal("off", f.Gateway.States[id].State));
|
||||
Assert.All(f.Lights.GetStatus().Lights, light => Assert.False(light.Running));
|
||||
var count = f.Gateway.Calls.Count; f.Time.Advance(TimeSpan.FromMinutes(5)); await Task.Delay(5);
|
||||
Assert.Equal(count, f.Gateway.Calls.Count);
|
||||
}
|
||||
finally { await f.Lights.StopAsync(default); }
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(15, "heat", 1)]
|
||||
[InlineData(30, "cool", 0)]
|
||||
[InlineData(22, "off", 0)]
|
||||
[InlineData(24, "off", 0)]
|
||||
[InlineData(25, "off", 0)]
|
||||
public async Task EnvironmentSensorDrivesActualSceneClimateAndBothHeatingLevels(double room, string mode, int heating)
|
||||
{
|
||||
var f = new Fixture(mattress: true);
|
||||
try
|
||||
{
|
||||
var prepared = f.Prepare(room);
|
||||
var result = await CompleteAsync(f.Coordinator.ExecutePreparedSleepAsync(_ => Task.FromResult(prepared), default), f.Time);
|
||||
Assert.True(result.Success); Assert.Equal(mode, result.ClimateAdvice!.Mode);
|
||||
Assert.False(result.ClimateAdvice.PendingIntegration);
|
||||
foreach (var id in new[] { "number.left_heat", "number.right_heat" }) Assert.Equal(heating.ToString(), f.Gateway.States[id].State);
|
||||
if (mode == "off")
|
||||
{
|
||||
Assert.Null(result.ClimateAdvice.TargetTemperatureCelsius);
|
||||
Assert.DoesNotContain(f.Gateway.Calls, call => call.EntityId == Climate);
|
||||
}
|
||||
else
|
||||
{
|
||||
Assert.Equal(mode, f.Gateway.States[Climate].State);
|
||||
Assert.InRange(result.ClimateAdvice.TargetTemperatureCelsius!.Value, 22, 25);
|
||||
Assert.Equal(result.ClimateAdvice.TargetTemperatureCelsius.Value, f.Gateway.States[Climate].GetDoubleAttribute("temperature"));
|
||||
Assert.Equal(new[] { "set_hvac_mode", "set_temperature" }, f.Gateway.Calls.Where(call => call.EntityId == Climate).Select(call => call.Service));
|
||||
}
|
||||
}
|
||||
finally { await f.Lights.StopAsync(default); }
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task MissingOrStaleEnvironmentNeverStartsClimateOrHeating()
|
||||
{
|
||||
var f = new Fixture();
|
||||
try
|
||||
{
|
||||
var prepared = f.Prepare(null);
|
||||
var result = await f.Coordinator.ExecuteAsync("sleep", prepared.Config, "balanced", default);
|
||||
Assert.Equal("unavailable", result.ClimateAdvice!.Mode);
|
||||
Assert.Equal(0, prepared.Config.MattressHeatingLevel);
|
||||
Assert.DoesNotContain(f.Gateway.Calls, call => call.EntityId == Climate);
|
||||
f.Prepare(15); f.Time.Advance(TimeSpan.FromMinutes(31));
|
||||
var comfort = new MattressComfortService(f.Gateway, new IndoorEnvironmentReader(f.Options, f.Time), f.Options).Get(null, f.Gateway.States);
|
||||
Assert.Equal("unavailable", comfort.ClimateAdvice.Mode); Assert.Equal(0, comfort.HeatingLevel);
|
||||
}
|
||||
finally { await f.Lights.StopAsync(default); }
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(true)]
|
||||
[InlineData(false)]
|
||||
public async Task ManualStandardControlCancelsOnlyThatLightsFade(bool strip)
|
||||
{
|
||||
var f = new Fixture();
|
||||
try
|
||||
{
|
||||
await f.Coordinator.ExecuteAsync("sleep", f.Prepare(24).Config, "balanced", default);
|
||||
var id = strip ? Strip : Primary;
|
||||
var other = strip ? Primary : Strip;
|
||||
var device = new HomeAssistantDiscoveredDeviceOutput
|
||||
{
|
||||
DeviceId = "manual", Online = true, PrimaryControlEntityId = id,
|
||||
Entities = [new() { EntityId = id, Domain = "light", State = "on", Available = true }]
|
||||
};
|
||||
var parameters = new DeviceParameterController(f.Modules, f.Runner, [new StandardLightControl()]);
|
||||
await parameters.ExecuteAsync(device, f.Gateway.States, "light_brightness", 77, id, default);
|
||||
Assert.False(f.Lights.ForEntity(id).GetStatus().Running); Assert.True(f.Lights.ForEntity(other).GetStatus().Running);
|
||||
f.Time.Advance(TimeSpan.FromSeconds(5));
|
||||
await WaitUntilAsync(() => f.Lights.ForEntity(other).GetStatus().BrightnessPercent == 49);
|
||||
Assert.Equal(77d / 100 * 255, f.Gateway.States[id].GetDoubleAttribute("brightness"));
|
||||
}
|
||||
finally { await f.Lights.StopAsync(default); }
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(Primary, Strip)]
|
||||
[InlineData(Strip, Primary)]
|
||||
public async Task OfflineLightDoesNotBlockOtherSceneLight(string offline, string active)
|
||||
{
|
||||
var f = new Fixture(); f.Gateway.States[offline] = Light(offline, "unavailable");
|
||||
try
|
||||
{
|
||||
var result = await f.Coordinator.ExecuteAsync("sleep", f.Prepare(24).Config, "balanced", default);
|
||||
Assert.True(result.Success); Assert.True(f.Lights.ForEntity(active).GetStatus().Running);
|
||||
Assert.DoesNotContain(f.Gateway.Calls, call => call.EntityId == offline);
|
||||
Assert.Contains(result.Actions, action => action.EntityId == offline && action.Status == "Skipped");
|
||||
}
|
||||
finally { await f.Lights.StopAsync(default); }
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UnsupportedDefaultEffectFailsBeforeStripTurnsOnAndDoesNotStopOtherLight()
|
||||
{
|
||||
var f = new Fixture(); f.Gateway.States[Strip] = Light(Strip, effects: ["阅读模式"]);
|
||||
try
|
||||
{
|
||||
var result = await f.Coordinator.ExecuteAsync("sleep", f.Prepare(24).Config, "balanced", default);
|
||||
Assert.Equal("Partial", result.Status);
|
||||
Assert.True(f.Lights.ForEntity(Primary).GetStatus().Running);
|
||||
Assert.DoesNotContain(f.Gateway.Calls, call => call.EntityId == Strip);
|
||||
Assert.Contains(result.Actions, action => action.EntityId == Strip && action.Status == "Failed");
|
||||
}
|
||||
finally { await f.Lights.StopAsync(default); }
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ModuleDiscoveryAndPreviewExposeOwnBindingAndDefaultWithoutHardwareWrites()
|
||||
{
|
||||
var f = new Fixture();
|
||||
var catalog = new HomeAssistantDeviceCatalog(f.Options, f.Modules);
|
||||
var discovered = catalog.BuildDiscoveredDevices(null, f.Gateway.States);
|
||||
var strip = Assert.Single(discovered.Where(device => device.PrimaryControlEntityId == Strip));
|
||||
Assert.Equal("sleep-light-strip", strip.ModuleKey); Assert.Equal("助眠模式", strip.DefaultLightEffect);
|
||||
Assert.Null(discovered.Single(device => device.PrimaryControlEntityId == Primary).DefaultLightEffect);
|
||||
var preview = f.Modules.ForEntities([Strip])!.BuildPreview(new(), "balanced")!;
|
||||
Assert.Equal(Strip, preview.EntityId); Assert.Contains("每5秒降低1%", preview.Detail);
|
||||
Assert.Empty(f.Gateway.Calls);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ProductionRegistrationAddsConfiguredStripWithoutCreatingASecondDeviceControlImplementation()
|
||||
{
|
||||
var services = new ServiceCollection(); services.AddOptions(); services.AddLogging(); services.AddHttpClient();
|
||||
services.AddSingleton<IOptions<HomeAssistantOptions>>(Options.Create(Settings()));
|
||||
services.AddHealthDevices(); services.AddSingleton<IHomeAssistantGateway>(new FakeHaGateway());
|
||||
services.AddSingleton<IAudioPlaybackGateway>(new FakeAudioGateway());
|
||||
using var provider = services.BuildServiceProvider();
|
||||
var registry = provider.GetRequiredService<DeviceModuleRegistry>();
|
||||
Assert.IsType<ConfiguredSleepLightModule>(registry.ForEntities([Strip]));
|
||||
Assert.IsType<LightingModule>(registry.ForEntities([Primary]));
|
||||
Assert.IsType<ClimateModule>(registry.ForEntities([Climate]));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DuplicateSceneLightEntityCannotCreateCompetingTimers()
|
||||
{
|
||||
var options = Settings(); options.AdditionalSleepLights.Add(new() { Key = "duplicate", Name = "重复灯", EntityId = Strip });
|
||||
Assert.Throws<InvalidOperationException>(() => new HomeAssistantLightTransitionService(new FakeHaGateway(), new([]),
|
||||
Options.Create(options), NullLogger<HomeAssistantLightTransitionService>.Instance, new(), TimeProvider.System));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
using Evergreen.Health.Application.Health.Devices;
|
||||
using Evergreen.Health.Application.Health.Devices.Contracts;
|
||||
using Evergreen.Health.Application.Health.Devices.Discovery;
|
||||
using Evergreen.Health.Application.Health.Devices.Lighting;
|
||||
using Evergreen.Health.Application.Health.Integrations.HomeAssistant;
|
||||
using Evergreen.Health.Application.Health.Models;
|
||||
using Evergreen.Health.Application.Health.Scenes;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Newtonsoft.Json.Linq;
|
||||
using System.Text.Json;
|
||||
using Xunit;
|
||||
|
||||
namespace Admin.NET.Test.Health;
|
||||
|
||||
/// <summary>标准能力、实例隔离和专属钩子全用假网关验证,不操作真实灯具。</summary>
|
||||
public sealed class StandardLightControlTests
|
||||
{
|
||||
private const string LightA = "light.test_a", LightB = "light.test_b";
|
||||
private static HomeAssistantState State(string id = LightA, string state = "on", string mode = "color_temp",
|
||||
int min = 2700, int max = 6500, string effect = "阅读") => new()
|
||||
{
|
||||
EntityId = id, State = state, Attributes = JsonSerializer.SerializeToElement(new
|
||||
{
|
||||
supported_color_modes = new[] { mode }, min_color_temp_kelvin = min, max_color_temp_kelvin = max,
|
||||
supported_features = 4, effect_list = new[] { effect, "日光" }, brightness = 128, color_temp_kelvin = 3000,
|
||||
effect, internal_config = 842138120
|
||||
})
|
||||
};
|
||||
private static HomeAssistantDiscoveredDeviceOutput Device(string id = LightA) => new()
|
||||
{
|
||||
DeviceId = id, PrimaryControlEntityId = id, Online = true,
|
||||
Entities = [new() { EntityId = id, Domain = "light", State = "on", Available = true }]
|
||||
};
|
||||
private static DeviceActionRunner Runner(IHomeAssistantGateway gateway) => new(gateway, TimeProvider.System,
|
||||
new() { VerificationDelay = TimeSpan.Zero });
|
||||
private static DeviceParameterController Controller(FakeHaGateway gateway, params IHealthDeviceModule[] modules)
|
||||
=> new(new DeviceModuleRegistry(modules), Runner(gateway), [new StandardLightControl()]);
|
||||
|
||||
[Theory]
|
||||
[InlineData("light_brightness", "0", "turn_off", null)]
|
||||
[InlineData("light_brightness", "50", "turn_on", "brightness_pct")]
|
||||
[InlineData("light_brightness", "100", "turn_on", "brightness_pct")]
|
||||
[InlineData("light_color_temperature", "2700", "turn_on", "color_temp_kelvin")]
|
||||
[InlineData("light_color_temperature", "6500", "turn_on", "color_temp_kelvin")]
|
||||
[InlineData("light_effect", "阅读", "turn_on", "effect")]
|
||||
public async Task NewLightNeedsNoModelModuleAndUsesOnlySupportedStandardServices(string parameter, string value, string service, string? field)
|
||||
{
|
||||
var gateway = new FakeHaGateway(); gateway.States[LightA] = State(state: "off");
|
||||
await Controller(gateway).ExecuteAsync(Device(), gateway.States, parameter, value, LightA, default);
|
||||
var call = Assert.Single(gateway.Calls);
|
||||
Assert.Equal("light", call.Domain); Assert.Equal(service, call.Service); Assert.Equal(LightA, call.EntityId);
|
||||
Assert.Equal(LightA, call.Data!["entity_id"]);
|
||||
if (field is null) Assert.Single(call.Data);
|
||||
else { Assert.Equal(2, call.Data.Count); Assert.Equal(value, call.Data[field]!.ToString()); }
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("light_brightness", "-1")]
|
||||
[InlineData("light_brightness", "101")]
|
||||
[InlineData("light_brightness", "10.5")]
|
||||
[InlineData("light_color_temperature", "2200")]
|
||||
[InlineData("light_color_temperature", "6501")]
|
||||
[InlineData("light_color_temperature", "3000.5")]
|
||||
[InlineData("light_effect", "删除所有遥控器")]
|
||||
[InlineData("button.press", "anything")]
|
||||
public async Task InvalidOrEngineeringParametersDoNotSendAnyCommand(string parameter, string value)
|
||||
{
|
||||
var gateway = new FakeHaGateway(); gateway.States[LightA] = State();
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(() => Controller(gateway).ExecuteAsync(
|
||||
Device(), gateway.States, parameter, value, null, default));
|
||||
Assert.Empty(gateway.Calls);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("onoff")]
|
||||
[InlineData("unknown")]
|
||||
public async Task OnOffOnlyOrUnknownModeDoesNotReceiveBrightnessOrColorTemperature(string mode)
|
||||
{
|
||||
var gateway = new FakeHaGateway(); gateway.States[LightA] = State(mode: mode);
|
||||
var controller = Controller(gateway);
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(() => controller.ExecuteAsync(Device(), gateway.States, "light_brightness", 50, null, default));
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(() => controller.ExecuteAsync(Device(), gateway.States, "light_color_temperature", 3000, null, default));
|
||||
Assert.Empty(gateway.Calls);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("brightness")]
|
||||
[InlineData("rgb")]
|
||||
[InlineData("rgbww")]
|
||||
public async Task DimmableLightWithoutColorTemperatureDoesNotInheritAnotherLampsTemperature(string mode)
|
||||
{
|
||||
var gateway = new FakeHaGateway(); gateway.States[LightA] = State(mode: mode);
|
||||
var controller = Controller(gateway);
|
||||
await controller.ExecuteAsync(Device(), gateway.States, "light_brightness", 50, null, default);
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(() => controller.ExecuteAsync(Device(), gateway.States, "light_color_temperature", 3000, null, default));
|
||||
Assert.Single(gateway.Calls);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TwoLightsUseIndependentRangesAndEffectLists()
|
||||
{
|
||||
var gateway = new FakeHaGateway();
|
||||
gateway.States[LightA] = State(min: 2200, max: 4500, effect: "阅读");
|
||||
gateway.States[LightB] = State(LightB, min: 3000, max: 6000, effect: "助眠");
|
||||
var controller = Controller(gateway);
|
||||
await controller.ExecuteAsync(Device(), gateway.States, "light_color_temperature", 2200, null, default);
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(() => controller.ExecuteAsync(Device(LightB), gateway.States, "light_color_temperature", 2200, null, default));
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(() => controller.ExecuteAsync(Device(LightB), gateway.States, "light_effect", "阅读", null, default));
|
||||
await controller.ExecuteAsync(Device(LightB), gateway.States, "light_effect", "助眠", null, default);
|
||||
Assert.Equal(new[] { LightA, LightB }, gateway.Calls.Select(call => call.EntityId));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("unknown")]
|
||||
[InlineData("unavailable")]
|
||||
[InlineData("")]
|
||||
public async Task MainLightAvailabilityCannotBeBorrowedFromAnotherEntity(string state)
|
||||
{
|
||||
var gateway = new FakeHaGateway(); gateway.States[LightA] = State(state: state); gateway.States[LightB] = State(LightB);
|
||||
var device = Device(); device.Entities.Add(new() { EntityId = LightB, Domain = "light", Available = true });
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(() => Controller(gateway).ExecuteAsync(device, gateway.States, "light_brightness", 50, null, default));
|
||||
Assert.Empty(gateway.Calls);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ForeignEntityEvenAnotherLightOnSameDeviceCannotOverrideTrustedPrimary()
|
||||
{
|
||||
var gateway = new FakeHaGateway(); gateway.States[LightA] = State(); gateway.States[LightB] = State(LightB);
|
||||
var device = Device(); device.Entities.Add(new() { EntityId = LightB, Domain = "light", Available = true });
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(() => Controller(gateway).ExecuteAsync(device, gateway.States, "light_brightness", 50, LightB, default));
|
||||
device.Entities.RemoveAt(0);
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(() => Controller(gateway).ExecuteAsync(device, gateway.States, "light_brightness", 50, null, default));
|
||||
device = Device(); device.Entities[0].Domain = "switch";
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(() => Controller(gateway).ExecuteAsync(device, gateway.States, "light_brightness", 50, null, default));
|
||||
Assert.Empty(gateway.Calls);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CapabilityChangeAfterRequestSnapshotIsRecheckedBeforeSend()
|
||||
{
|
||||
var gateway = new FakeHaGateway(); gateway.States[LightA] = State(mode: "onoff");
|
||||
var snapshot = new Dictionary<string, HomeAssistantState> { [LightA] = State() };
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(() => Controller(gateway).ExecuteAsync(Device(), snapshot, "light_brightness", 50, null, default));
|
||||
Assert.Empty(gateway.Calls);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task MissingRangeOrEffectFlagDoesNotEnableTheseControls()
|
||||
{
|
||||
var gateway = new FakeHaGateway();
|
||||
gateway.States[LightA] = new() { EntityId = LightA, State = "on", Attributes = JsonSerializer.SerializeToElement(new
|
||||
{ supported_color_modes = new[] { "color_temp" }, effect_list = new[] { "阅读" }, supported_features = 0 }) };
|
||||
var controller = Controller(gateway);
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(() => controller.ExecuteAsync(Device(), gateway.States, "light_color_temperature", 3000, null, default));
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(() => controller.ExecuteAsync(Device(), gateway.States, "light_effect", "阅读", null, default));
|
||||
Assert.Empty(gateway.Calls);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CancellationPreventsStandardWrite()
|
||||
{
|
||||
var gateway = new FakeHaGateway(); gateway.States[LightA] = State();
|
||||
using var cancellation = new CancellationTokenSource(); cancellation.Cancel();
|
||||
await Assert.ThrowsAnyAsync<OperationCanceledException>(() => Controller(gateway).ExecuteAsync(
|
||||
Device(), gateway.States, "light_brightness", 50, null, cancellation.Token));
|
||||
Assert.Empty(gateway.Calls);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task BoundSceneLightKeepsItsHooksButNewLightDoesNotTriggerThem()
|
||||
{
|
||||
var gateway = new FakeHaGateway(); gateway.States[LightA] = State(); gateway.States[LightB] = State(LightB);
|
||||
var runtime = new CountingLight(); var handler = new CountingOffHandler();
|
||||
var module = new LightingModule(Runner(gateway), Options.Create(new HomeAssistantOptions { Entities = new() { AmbientLight = LightA } }),
|
||||
runtime, new LightOffDispatcher([handler]));
|
||||
var controller = Controller(gateway, module);
|
||||
await controller.ExecuteAsync(Device(LightB), gateway.States, "light_brightness", 0, null, default);
|
||||
Assert.Equal(0, runtime.Cancels); Assert.Equal(0, handler.Calls);
|
||||
await controller.ExecuteAsync(Device(), gateway.States, "light_brightness", 0, null, default);
|
||||
Assert.Equal(1, runtime.Cancels); Assert.Equal(1, handler.Calls);
|
||||
Assert.Empty(gateway.Calls.Where(call => call.EntityId is not (LightA or LightB)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExistingSpecialParametersStillRouteOnlyToTheirOwningModule()
|
||||
{
|
||||
var gateway = new FakeHaGateway(); gateway.States["number.synthetic"] = new() { EntityId = "number.synthetic", State = "0" };
|
||||
var module = new SyntheticDeviceModule(Runner(gateway));
|
||||
var device = new HomeAssistantDiscoveredDeviceOutput { DeviceId = "special", Entities = [new() { EntityId = "number.synthetic", Domain = "number" }] };
|
||||
await Controller(gateway, module).ExecuteAsync(device, gateway.States, "synthetic_value", 3, null, default);
|
||||
Assert.Equal("number.synthetic", Assert.Single(gateway.Calls).EntityId);
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(() => Controller(gateway, module).ExecuteAsync(Device(), gateway.States, "synthetic_value", 3, null, default));
|
||||
Assert.Single(gateway.Calls);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LightCapabilitiesSurviveActualApiSerializerWithoutExposingPackedInternalFields()
|
||||
{
|
||||
var safe = HomeAssistantDeviceCatalog.ExtractSafeAttributes(State())!;
|
||||
var json = JObject.Parse(Newtonsoft.Json.JsonConvert.SerializeObject(safe));
|
||||
Assert.Equal(2700, json["min_color_temp_kelvin"]!.Value<int>());
|
||||
Assert.Equal(6500, json["max_color_temp_kelvin"]!.Value<int>());
|
||||
Assert.Equal("color_temp", json["supported_color_modes"]![0]!.Value<string>());
|
||||
Assert.Equal("阅读", json["effect_list"]![0]!.Value<string>());
|
||||
Assert.Null(json["internal_config"]);
|
||||
}
|
||||
|
||||
private sealed class CountingOffHandler : ILightOffHandler
|
||||
{
|
||||
public int Calls;
|
||||
public Task HandleAsync(CancellationToken ct) { ct.ThrowIfCancellationRequested(); Calls++; return Task.CompletedTask; }
|
||||
}
|
||||
private sealed class CountingLight : ILightTransitionRuntime
|
||||
{
|
||||
public int Cancels;
|
||||
public HomeAssistantLightTransitionOutput GetStatus() => new();
|
||||
public Task<HomeAssistantLightTransitionOutput> StartSleepAsync(int kelvin, CancellationToken ct) => Task.FromResult(new HomeAssistantLightTransitionOutput());
|
||||
public Task<HomeAssistantLightTransitionOutput> StartWakeAsync(CancellationToken ct) => Task.FromResult(new HomeAssistantLightTransitionOutput());
|
||||
public Task<HomeAssistantLightTransitionOutput> CancelAsync(CancellationToken ct) { Cancels++; return Task.FromResult(new HomeAssistantLightTransitionOutput()); }
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user