11
This commit is contained in:
@@ -37,11 +37,14 @@ internal sealed class FakeHaGateway : IHomeAssistantGateway
|
|||||||
"select_option" => data["option"]!.ToString()!,
|
"select_option" => data["option"]!.ToString()!,
|
||||||
"set_value" => data["value"]!.ToString()!, _ => States.GetValueOrDefault(entity)?.State ?? "unknown"
|
"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?>();
|
var attributes = new Dictionary<string, object?>();
|
||||||
if (States.TryGetValue(entity, out var previous) && previous.Attributes.ValueKind == JsonValueKind.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();
|
foreach (var property in previous.Attributes.EnumerateObject()) attributes[property.Name] = property.Value.Clone();
|
||||||
if (data.TryGetValue("brightness_pct", out var brightness))
|
if (data.TryGetValue("brightness_pct", out var brightness))
|
||||||
attributes["brightness"] = Convert.ToDouble(brightness) / 100 * 255;
|
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(),
|
States[entity] = new() { EntityId = entity, State = state, LastReported = Time.GetUtcNow(), LastUpdated = Time.GetUtcNow(),
|
||||||
Attributes = JsonSerializer.SerializeToElement(attributes) };
|
Attributes = JsonSerializer.SerializeToElement(attributes) };
|
||||||
return Task.CompletedTask;
|
return Task.CompletedTask;
|
||||||
|
|||||||
@@ -28,6 +28,8 @@ public sealed class HomeAssistantAttributeSerializationTests
|
|||||||
fan_modes = new[] { "自动", "低风", "中风", "高风" },
|
fan_modes = new[] { "自动", "低风", "中风", "高风" },
|
||||||
swing_modes = new[] { "off", "vertical" },
|
swing_modes = new[] { "off", "vertical" },
|
||||||
options = new[] { "智能适配", "轻柔", "全身" },
|
options = new[] { "智能适配", "轻柔", "全身" },
|
||||||
|
supported_color_modes = new[] { "color_temp" },
|
||||||
|
effect_list = new[] { "阅读模式", "助眠模式" },
|
||||||
current_temperature = 24.9,
|
current_temperature = 24.9,
|
||||||
temperature = 23,
|
temperature = 23,
|
||||||
temperature_unit = "°C",
|
temperature_unit = "°C",
|
||||||
@@ -50,6 +52,8 @@ public sealed class HomeAssistantAttributeSerializationTests
|
|||||||
[InlineData("fan_modes")]
|
[InlineData("fan_modes")]
|
||||||
[InlineData("swing_modes")]
|
[InlineData("swing_modes")]
|
||||||
[InlineData("options")]
|
[InlineData("options")]
|
||||||
|
[InlineData("supported_color_modes")]
|
||||||
|
[InlineData("effect_list")]
|
||||||
public void ApiSerializesChoiceArraysAsStringsNotJsonElementMetadata(string key)
|
public void ApiSerializesChoiceArraysAsStringsNotJsonElementMetadata(string key)
|
||||||
{
|
{
|
||||||
var state = State();
|
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()); }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -49,6 +49,14 @@
|
|||||||
"Formaldehyde": "sensor.mirsz_cn_2027039858_et71_hcho_density_p_3_7",
|
"Formaldehyde": "sensor.mirsz_cn_2027039858_et71_hcho_density_p_3_7",
|
||||||
"Tvoc": "sensor.mirsz_cn_2027039858_et71_tvoc_density_p_3_8"
|
"Tvoc": "sensor.mirsz_cn_2027039858_et71_tvoc_density_p_3_8"
|
||||||
},
|
},
|
||||||
|
"AdditionalSleepLights": [
|
||||||
|
{
|
||||||
|
"Key": "sleep-light-strip",
|
||||||
|
"Name": "烛照智能灯带Ultra2.0",
|
||||||
|
"EntityId": "light.zhunen_cn_2205208916_wy0a01_s_2_light",
|
||||||
|
"SleepEffect": "助眠模式"
|
||||||
|
}
|
||||||
|
],
|
||||||
"SleepScene": {
|
"SleepScene": {
|
||||||
"TargetHumidity": 55,
|
"TargetHumidity": 55,
|
||||||
"DehumidifierMode": "睡眠",
|
"DehumidifierMode": "睡眠",
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
using Evergreen.Health.Application.Health.Integrations.HomeAssistant;
|
||||||
|
using Evergreen.Health.Application.Health.Models;
|
||||||
|
|
||||||
|
namespace Evergreen.Health.Application.Health.Devices.Contracts;
|
||||||
|
|
||||||
|
/// <summary>按设备实例实际能力处理标准手动参数,不绑定单一型号,也不登记自动场景。</summary>
|
||||||
|
public interface IStandardDeviceControl
|
||||||
|
{
|
||||||
|
IReadOnlyList<string> ParameterKeys { get; }
|
||||||
|
DeviceCommand Build(HomeAssistantDiscoveredDeviceOutput device, HomeAssistantState state,
|
||||||
|
string parameterType, object? value);
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
using Evergreen.Health.Application.Health.Devices.Contracts;
|
||||||
|
using Evergreen.Health.Application.Health.Integrations.HomeAssistant;
|
||||||
|
using Evergreen.Health.Application.Health.Models;
|
||||||
|
using Evergreen.Health.Application.Health.Scenes;
|
||||||
|
|
||||||
|
namespace Evergreen.Health.Application.Health.Devices;
|
||||||
|
|
||||||
|
/// <summary>共用归属校验和路由;标准能力处理普通参数,专属模块保留自己的执行与场景逻辑。</summary>
|
||||||
|
public sealed class DeviceParameterController
|
||||||
|
{
|
||||||
|
private readonly DeviceModuleRegistry _modules;
|
||||||
|
private readonly DeviceActionRunner _runner;
|
||||||
|
private readonly Dictionary<string, IStandardDeviceControl> _standard = new(StringComparer.OrdinalIgnoreCase);
|
||||||
|
public DeviceParameterController(DeviceModuleRegistry modules, DeviceActionRunner runner,
|
||||||
|
IEnumerable<IStandardDeviceControl> standard)
|
||||||
|
{
|
||||||
|
(_modules, _runner) = (modules, runner);
|
||||||
|
foreach (var adapter in standard)
|
||||||
|
foreach (var key in adapter.ParameterKeys)
|
||||||
|
if (!_standard.TryAdd(key, adapter)) throw new InvalidOperationException($"标准设备参数重复:{key}");
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<DeviceCommand> ExecuteAsync(HomeAssistantDiscoveredDeviceOutput device,
|
||||||
|
IReadOnlyDictionary<string, HomeAssistantState> states, string parameterType, object? value,
|
||||||
|
string? requestedEntityId, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
|
IHealthDeviceModule? module;
|
||||||
|
DeviceCommand command;
|
||||||
|
if (_standard.TryGetValue(parameterType, out var adapter))
|
||||||
|
{
|
||||||
|
var primary = device.PrimaryControlEntityId;
|
||||||
|
if (string.IsNullOrWhiteSpace(primary) || !device.Entities.Any(entity =>
|
||||||
|
string.Equals(entity.EntityId, primary, StringComparison.OrdinalIgnoreCase)) ||
|
||||||
|
!states.TryGetValue(primary, out var state))
|
||||||
|
throw new InvalidOperationException("设备主控实体未确认");
|
||||||
|
command = adapter.Build(device, state, parameterType, value);
|
||||||
|
module = _modules.ForEntities(device.Entities.Select(entity => entity.EntityId));
|
||||||
|
// 附属灯不能借用另一主控的执行钩子;仅复用该实例所属的专属模块。
|
||||||
|
if (module is not null && !string.Equals(module.PrimaryEntityId, command.EntityId, StringComparison.OrdinalIgnoreCase))
|
||||||
|
module = null;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
module = _modules.ForParameter(parameterType);
|
||||||
|
command = module.BuildParameterCommand(parameterType, value);
|
||||||
|
}
|
||||||
|
if (string.IsNullOrWhiteSpace(command.EntityId))
|
||||||
|
throw new InvalidOperationException("该参数尚未配置 Home Assistant 实体");
|
||||||
|
if (!string.IsNullOrWhiteSpace(requestedEntityId) &&
|
||||||
|
!string.Equals(requestedEntityId, command.EntityId, StringComparison.OrdinalIgnoreCase))
|
||||||
|
throw new InvalidOperationException("请求实体与该参数的受信任实体不匹配");
|
||||||
|
if (!device.Entities.Any(entity => string.Equals(entity.EntityId, command.EntityId, StringComparison.OrdinalIgnoreCase)))
|
||||||
|
throw new InvalidOperationException("该参数不属于指定设备");
|
||||||
|
if (module?.ParameterUsesIndependentGateway(command) != true &&
|
||||||
|
(!states.TryGetValue(command.EntityId, out var current) || !DeviceActionRunner.IsAvailable(current)))
|
||||||
|
throw new InvalidOperationException("目标参数实体当前离线或不可用");
|
||||||
|
if (module is not null) await module.ExecuteParameterAsync(command, cancellationToken);
|
||||||
|
else
|
||||||
|
{
|
||||||
|
await _runner.SendAsync(command, cancellationToken);
|
||||||
|
if (command.ExpectedState is not null) await _runner.VerifyParameterAsync(command, cancellationToken);
|
||||||
|
}
|
||||||
|
return command;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -88,6 +88,7 @@ public sealed class HomeAssistantDeviceCatalog
|
|||||||
Category = category,
|
Category = category,
|
||||||
Online = isOnline,
|
Online = isOnline,
|
||||||
PrimaryControlEntityId = primaryControlEntityId,
|
PrimaryControlEntityId = primaryControlEntityId,
|
||||||
|
DefaultLightEffect = DefaultLightEffect(primaryControlEntityId),
|
||||||
Entities = entityOutputs,
|
Entities = entityOutputs,
|
||||||
Metrics = metrics
|
Metrics = metrics
|
||||||
});
|
});
|
||||||
@@ -125,12 +126,16 @@ public sealed class HomeAssistantDeviceCatalog
|
|||||||
DeviceId = $"fallback_{module.Key}", ModuleKey = module.Key, Name = module.DisplayName,
|
DeviceId = $"fallback_{module.Key}", ModuleKey = module.Key, Name = module.DisplayName,
|
||||||
Category = module.Category, Online = entityOutputs.Any(entity => entity.Available),
|
Category = module.Category, Online = entityOutputs.Any(entity => entity.Available),
|
||||||
PrimaryControlEntityId = FindPrimaryControlEntityId(module.Category, entityOutputs),
|
PrimaryControlEntityId = FindPrimaryControlEntityId(module.Category, entityOutputs),
|
||||||
|
DefaultLightEffect = DefaultLightEffect(module.PrimaryEntityId),
|
||||||
Entities = entityOutputs, Metrics = ExtractDeviceMetrics(module.Category, entityOutputs)
|
Entities = entityOutputs, Metrics = ExtractDeviceMetrics(module.Category, entityOutputs)
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
return list;
|
return list;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private string? DefaultLightEffect(string? entityId) => _options.AdditionalSleepLights.FirstOrDefault(light =>
|
||||||
|
string.Equals(light.EntityId, entityId, StringComparison.OrdinalIgnoreCase))?.SleepEffect;
|
||||||
|
|
||||||
private string? FindPrimaryControlEntityId(
|
private string? FindPrimaryControlEntityId(
|
||||||
string category,
|
string category,
|
||||||
IReadOnlyList<HomeAssistantEntityOutput> entities)
|
IReadOnlyList<HomeAssistantEntityOutput> entities)
|
||||||
@@ -301,6 +306,7 @@ public sealed class HomeAssistantDeviceCatalog
|
|||||||
"friendly_name", "unit_of_measurement", "device_class",
|
"friendly_name", "unit_of_measurement", "device_class",
|
||||||
"current_humidity", "humidity", "mode", "fan_mode", "percentage",
|
"current_humidity", "humidity", "mode", "fan_mode", "percentage",
|
||||||
"brightness", "color_temp", "color_temp_kelvin",
|
"brightness", "color_temp", "color_temp_kelvin",
|
||||||
|
"supported_color_modes", "color_mode", "min_color_temp_kelvin", "max_color_temp_kelvin", "effect_list", "effect",
|
||||||
"volume_level", "is_volume_muted", "media_title",
|
"volume_level", "is_volume_muted", "media_title",
|
||||||
"battery_level", "temperature", "options", "min", "max", "step", "hvac_modes", "supported_features",
|
"battery_level", "temperature", "options", "min", "max", "step", "hvac_modes", "supported_features",
|
||||||
"current_temperature", "temperature_unit", "min_temp", "max_temp", "target_temp_step",
|
"current_temperature", "temperature_unit", "min_temp", "max_temp", "target_temp_step",
|
||||||
|
|||||||
@@ -61,7 +61,11 @@ public static class HealthDeviceRegistration
|
|||||||
services.AddSingleton<IHealthDeviceModule>(provider => provider.GetRequiredService<DehumidifierModule>());
|
services.AddSingleton<IHealthDeviceModule>(provider => provider.GetRequiredService<DehumidifierModule>());
|
||||||
services.AddSingleton<FragranceModule>();
|
services.AddSingleton<FragranceModule>();
|
||||||
services.AddSingleton<IHealthDeviceModule>(provider => provider.GetRequiredService<FragranceModule>());
|
services.AddSingleton<IHealthDeviceModule>(provider => provider.GetRequiredService<FragranceModule>());
|
||||||
services.AddSingleton<LightingModule>();
|
services.AddSingleton<LightingModule>(provider => new(
|
||||||
|
provider.GetRequiredService<DeviceActionRunner>(), provider.GetRequiredService<IOptions<HomeAssistantOptions>>(),
|
||||||
|
provider.GetRequiredService<HomeAssistantLightTransitionService>().ForEntity(
|
||||||
|
provider.GetRequiredService<IOptions<HomeAssistantOptions>>().Value.Entities.AmbientLight),
|
||||||
|
provider.GetRequiredService<LightOffDispatcher>()));
|
||||||
services.AddSingleton<IHealthDeviceModule>(provider => provider.GetRequiredService<LightingModule>());
|
services.AddSingleton<IHealthDeviceModule>(provider => provider.GetRequiredService<LightingModule>());
|
||||||
services.AddSingleton<AudioModule>();
|
services.AddSingleton<AudioModule>();
|
||||||
services.AddSingleton<IHealthDeviceModule>(provider => provider.GetRequiredService<AudioModule>());
|
services.AddSingleton<IHealthDeviceModule>(provider => provider.GetRequiredService<AudioModule>());
|
||||||
@@ -69,7 +73,13 @@ public static class HealthDeviceRegistration
|
|||||||
services.AddSingleton<IHealthDeviceModule>(provider => provider.GetRequiredService<MattressModule>());
|
services.AddSingleton<IHealthDeviceModule>(provider => provider.GetRequiredService<MattressModule>());
|
||||||
services.AddSingleton<ILightOffHandler>(provider => provider.GetRequiredService<MattressModule>());
|
services.AddSingleton<ILightOffHandler>(provider => provider.GetRequiredService<MattressModule>());
|
||||||
services.AddSingleton<LightOffDispatcher>();
|
services.AddSingleton<LightOffDispatcher>();
|
||||||
services.AddSingleton<DeviceModuleRegistry>();
|
services.AddSingleton<DeviceModuleRegistry>(provider => new(
|
||||||
|
provider.GetServices<IHealthDeviceModule>().Concat(
|
||||||
|
provider.GetRequiredService<IOptions<HomeAssistantOptions>>().Value.AdditionalSleepLights.Select(light =>
|
||||||
|
new ConfiguredSleepLightModule(provider.GetRequiredService<DeviceActionRunner>(), light,
|
||||||
|
provider.GetRequiredService<HomeAssistantLightTransitionService>().ForEntity(light.EntityId))))));
|
||||||
|
services.AddSingleton<IStandardDeviceControl, StandardLightControl>();
|
||||||
|
services.AddSingleton<DeviceParameterController>();
|
||||||
services.AddSingleton<HomeAssistantDeviceCatalog>();
|
services.AddSingleton<HomeAssistantDeviceCatalog>();
|
||||||
services.AddSingleton<HealthSceneCoordinator>();
|
services.AddSingleton<HealthSceneCoordinator>();
|
||||||
return services;
|
return services;
|
||||||
|
|||||||
@@ -0,0 +1,51 @@
|
|||||||
|
using Evergreen.Health.Application.Health.Devices.Contracts;
|
||||||
|
using Evergreen.Health.Application.Health.Integrations.HomeAssistant;
|
||||||
|
using Evergreen.Health.Application.Health.Models;
|
||||||
|
using Evergreen.Health.Application.Health.Scenes;
|
||||||
|
|
||||||
|
namespace Evergreen.Health.Application.Health.Devices.Lighting;
|
||||||
|
|
||||||
|
/// <summary>额外场景灯复用标准参数和单灯渐变;型号专属模式通过配置选择,不套用原灯的按摩/唤醒钩子。</summary>
|
||||||
|
public sealed class ConfiguredSleepLightModule : HealthDeviceModule
|
||||||
|
{
|
||||||
|
private readonly SceneLightOptions _light;
|
||||||
|
private readonly ILightTransitionRuntime _transition;
|
||||||
|
public ConfiguredSleepLightModule(DeviceActionRunner runner, SceneLightOptions light, ILightTransitionRuntime transition) : base(runner)
|
||||||
|
=> (_light, _transition) = (light, transition);
|
||||||
|
public override string Key => _light.Key;
|
||||||
|
public override string Category => nameof(HealthDeviceCategory.Lighting);
|
||||||
|
public override string DisplayName => _light.Name;
|
||||||
|
public override string PrimaryEntityId => _light.EntityId;
|
||||||
|
public override IReadOnlyList<DeviceParameterDefinition> Parameters => [];
|
||||||
|
|
||||||
|
public override async Task ExecuteParameterAsync(DeviceCommand command, CancellationToken token)
|
||||||
|
{
|
||||||
|
await _transition.CancelAsync(token);
|
||||||
|
await base.ExecuteParameterAsync(command, token);
|
||||||
|
}
|
||||||
|
public override async Task SetPowerAsync(bool turnOn, CancellationToken token)
|
||||||
|
{
|
||||||
|
if (turnOn) { await _transition.StartSleepAsync(2700, token); return; }
|
||||||
|
await _transition.CancelAsync(token);
|
||||||
|
var command = GetShutdownCommands().Single();
|
||||||
|
await Runner.SendAsync(command, token);
|
||||||
|
await Runner.VerifyParameterAsync(command, token);
|
||||||
|
}
|
||||||
|
public override async Task ExecuteSleepAsync(DeviceSceneContext context, PersonalizedSceneConfig config,
|
||||||
|
string constitutionCode, CancellationToken token)
|
||||||
|
{
|
||||||
|
if (!context.States.TryGetValue(PrimaryEntityId, out var state) || !DeviceActionRunner.IsAvailable(state))
|
||||||
|
{
|
||||||
|
context.Results.Add(new() { Name = $"开启{DisplayName}助眠渐暗", EntityId = PrimaryEntityId,
|
||||||
|
Status = "Skipped", Message = "场景灯当前离线或不可用" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await context.ExternalAsync($"开启{DisplayName}助眠渐暗", PrimaryEntityId,
|
||||||
|
async cancellation => { await _transition.StartSleepAsync(config.LightColorTemperatureKelvin, cancellation); }, token);
|
||||||
|
}
|
||||||
|
public override IEnumerable<DeviceCommand> GetShutdownCommands()
|
||||||
|
=> [new($"关闭{DisplayName}", PrimaryEntityId, "light", "turn_off", ExpectedState: "off")];
|
||||||
|
public override HomeAssistantScenePresetItem? BuildPreview(PersonalizedSceneConfig config, string constitutionCode)
|
||||||
|
=> Preview("开启助眠渐暗灯光", $"{_light.SleepEffect ?? "助眠渐暗"} · 从50%开始,每5秒降低1%,最终关灯",
|
||||||
|
new() { ["brightness"] = "50%→关闭", ["effect"] = _light.SleepEffect });
|
||||||
|
}
|
||||||
@@ -38,9 +38,13 @@ public sealed class LightingModule : HealthDeviceModule
|
|||||||
|
|
||||||
public override async Task ExecuteParameterAsync(DeviceCommand command, CancellationToken cancellationToken)
|
public override async Task ExecuteParameterAsync(DeviceCommand command, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
|
// 标准手动面板复用本灯的专属安全钩子;其他灯不会触发助眠灯/床垫联动。
|
||||||
|
command = command with { Intent = command.Service == "turn_off"
|
||||||
|
? DeviceCommandIntent.TurnRelaxationLightOff : DeviceCommandIntent.TurnRelaxationLightOn };
|
||||||
await _transition.CancelAsync(cancellationToken);
|
await _transition.CancelAsync(cancellationToken);
|
||||||
await base.ExecuteParameterAsync(command, cancellationToken);
|
await Runner.SendAsync(command, cancellationToken);
|
||||||
if (command.Service == "turn_off") await _lightOff.NotifyAsync(cancellationToken);
|
if (command.Service == "turn_off") await _lightOff.NotifyAsync(cancellationToken);
|
||||||
|
if (command.ExpectedState is not null) await Runner.VerifyParameterAsync(command, cancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
public override async Task SetPowerAsync(bool turnOn, CancellationToken cancellationToken)
|
public override async Task SetPowerAsync(bool turnOn, CancellationToken cancellationToken)
|
||||||
|
|||||||
@@ -0,0 +1,79 @@
|
|||||||
|
using Evergreen.Health.Application.Health.Devices.Contracts;
|
||||||
|
using Evergreen.Health.Application.Health.Integrations.HomeAssistant;
|
||||||
|
using Evergreen.Health.Application.Health.Models;
|
||||||
|
using System.Text.Json;
|
||||||
|
|
||||||
|
namespace Evergreen.Health.Application.Health.Devices.Lighting;
|
||||||
|
|
||||||
|
/// <summary>标准灯光参数依赖 HA 上报能力,不能将一个型号的色温范围或模式复制给另一盏灯。</summary>
|
||||||
|
public sealed class StandardLightControl : IStandardDeviceControl
|
||||||
|
{
|
||||||
|
public IReadOnlyList<string> ParameterKeys => ["light_brightness", "light_color_temperature", "light_effect"];
|
||||||
|
private static readonly string[] DimmableModes = ["brightness", "color_temp", "hs", "rgb", "rgbw", "rgbww", "xy", "white"];
|
||||||
|
|
||||||
|
public DeviceCommand Build(HomeAssistantDiscoveredDeviceOutput device, HomeAssistantState state,
|
||||||
|
string parameterType, object? value)
|
||||||
|
{
|
||||||
|
var entityId = device.PrimaryControlEntityId ?? "";
|
||||||
|
if (!entityId.StartsWith("light.", StringComparison.Ordinal) ||
|
||||||
|
!device.Entities.Any(entity => entity.EntityId == entityId && entity.Domain == "light"))
|
||||||
|
throw new InvalidOperationException("该设备没有受信任的灯光主控能力");
|
||||||
|
Validate(state, entityId, parameterType, value);
|
||||||
|
var command = parameterType switch
|
||||||
|
{
|
||||||
|
"light_brightness" => Brightness(entityId, value),
|
||||||
|
"light_color_temperature" => new DeviceCommand("设置灯光色温", entityId, "light", "turn_on",
|
||||||
|
new Dictionary<string, object?> { ["color_temp_kelvin"] = DeviceValues.Integer(value, 1, int.MaxValue, "灯光色温") }),
|
||||||
|
"light_effect" => new DeviceCommand("设置灯光模式", entityId, "light", "turn_on",
|
||||||
|
new Dictionary<string, object?> { ["effect"] = DeviceValues.Text(value) }),
|
||||||
|
_ => throw new InvalidOperationException("不支持的标准灯光参数")
|
||||||
|
};
|
||||||
|
return command with { ValidateCurrentState = current => Validate(current, entityId, parameterType, value),
|
||||||
|
RetryOnVerificationMismatch = false };
|
||||||
|
}
|
||||||
|
|
||||||
|
private static DeviceCommand Brightness(string entityId, object? value)
|
||||||
|
{
|
||||||
|
var brightness = DeviceValues.Integer(value, 0, 100, "灯光亮度");
|
||||||
|
return new("设置灯光亮度", entityId, "light", brightness == 0 ? "turn_off" : "turn_on",
|
||||||
|
brightness == 0 ? null : new Dictionary<string, object?> { ["brightness_pct"] = brightness },
|
||||||
|
brightness == 0 ? "off" : null);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void Validate(HomeAssistantState state, string entityId, string parameterType, object? value)
|
||||||
|
{
|
||||||
|
if (state.EntityId != entityId || state.State is not ("on" or "off"))
|
||||||
|
throw new InvalidOperationException("灯光主控当前离线或状态未确认");
|
||||||
|
var modes = Strings(state, "supported_color_modes");
|
||||||
|
switch (parameterType)
|
||||||
|
{
|
||||||
|
case "light_brightness":
|
||||||
|
if (!modes.Any(mode => DimmableModes.Contains(mode, StringComparer.Ordinal)))
|
||||||
|
throw new InvalidOperationException("这盏灯未声明支持亮度调节");
|
||||||
|
DeviceValues.Integer(value, 0, 100, "灯光亮度");
|
||||||
|
break;
|
||||||
|
case "light_color_temperature":
|
||||||
|
var min = state.GetDoubleAttribute("min_color_temp_kelvin");
|
||||||
|
var max = state.GetDoubleAttribute("max_color_temp_kelvin");
|
||||||
|
if (!modes.Contains("color_temp") || min is null || max is null || min <= 0 || max <= min ||
|
||||||
|
max > int.MaxValue || min != Math.Truncate(min.Value) || max != Math.Truncate(max.Value))
|
||||||
|
throw new InvalidOperationException("这盏灯的色温能力或范围未确认");
|
||||||
|
DeviceValues.Integer(value, (int)min.Value, (int)max.Value, "灯光色温");
|
||||||
|
break;
|
||||||
|
case "light_effect":
|
||||||
|
var features = state.GetDoubleAttribute("supported_features");
|
||||||
|
if (features is null || features < 0 || features > int.MaxValue || features != Math.Truncate(features.Value) ||
|
||||||
|
(((int)features.Value & 4) == 0)) throw new InvalidOperationException("这盏灯未声明支持灯光模式");
|
||||||
|
DeviceValues.Choice(value, Strings(state, "effect_list"), "灯光模式");
|
||||||
|
break;
|
||||||
|
default: throw new InvalidOperationException("不支持的标准灯光参数");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string[] Strings(HomeAssistantState state, string key)
|
||||||
|
=> state.Attributes.ValueKind == JsonValueKind.Object && state.Attributes.TryGetProperty(key, out var values) &&
|
||||||
|
values.ValueKind == JsonValueKind.Array
|
||||||
|
? values.EnumerateArray().Where(value => value.ValueKind == JsonValueKind.String)
|
||||||
|
.Select(value => value.GetString()!).Where(value => !string.IsNullOrWhiteSpace(value)).Distinct(StringComparer.Ordinal).ToArray()
|
||||||
|
: [];
|
||||||
|
}
|
||||||
+47
-246
@@ -6,268 +6,69 @@ using Microsoft.Extensions.Options;
|
|||||||
|
|
||||||
namespace Evergreen.Health.Application.Health.Integrations.HomeAssistant;
|
namespace Evergreen.Health.Application.Health.Integrations.HomeAssistant;
|
||||||
|
|
||||||
/// <summary>在服务端驱动卧室灯光渐变,浏览器关闭后任务仍继续。</summary>
|
/// <summary>管理明确配置的场景灯;共享单灯渐变实现,但不共享亮度、计时或取消状态。</summary>
|
||||||
public sealed class HomeAssistantLightTransitionService : IHostedService, ILightTransitionRuntime
|
public sealed class HomeAssistantLightTransitionService : IHostedService, ILightTransitionRuntime
|
||||||
{
|
{
|
||||||
private const int SleepStartPercent = 50;
|
private readonly LightTransitionWorker _primary;
|
||||||
private const int WakeTargetPercent = 70;
|
private readonly Dictionary<string, LightTransitionWorker> _lights = new(StringComparer.OrdinalIgnoreCase);
|
||||||
private static readonly TimeSpan StepInterval = TimeSpan.FromSeconds(5);
|
|
||||||
|
|
||||||
private readonly IHomeAssistantGateway _homeAssistant;
|
public HomeAssistantLightTransitionService(IHomeAssistantGateway gateway, LightOffDispatcher lightOff,
|
||||||
private readonly LightOffDispatcher _lightOff;
|
IOptions<HomeAssistantOptions> options, ILogger<HomeAssistantLightTransitionService> logger,
|
||||||
private readonly SceneLightSafetyState _safety;
|
SceneLightSafetyState safety, TimeProvider time)
|
||||||
private readonly TimeProvider _time;
|
|
||||||
private readonly HomeAssistantOptions _options;
|
|
||||||
private readonly ILogger<HomeAssistantLightTransitionService> _logger;
|
|
||||||
private readonly SemaphoreSlim _operationLock = new(1, 1);
|
|
||||||
private readonly object _statusLock = new();
|
|
||||||
private CancellationTokenSource? _activeCancellation;
|
|
||||||
private Task? _activeTask;
|
|
||||||
private HomeAssistantLightTransitionOutput _status = new();
|
|
||||||
|
|
||||||
public HomeAssistantLightTransitionService(
|
|
||||||
IHomeAssistantGateway homeAssistant,
|
|
||||||
LightOffDispatcher lightOff,
|
|
||||||
IOptions<HomeAssistantOptions> options,
|
|
||||||
ILogger<HomeAssistantLightTransitionService> logger,
|
|
||||||
SceneLightSafetyState safety,
|
|
||||||
TimeProvider time)
|
|
||||||
{
|
{
|
||||||
_options = options.Value;
|
_primary = new(gateway, lightOff, options, logger, safety, time);
|
||||||
_logger = logger;
|
var primaryId = options.Value.Entities.AmbientLight;
|
||||||
_homeAssistant = homeAssistant;
|
if (!string.IsNullOrWhiteSpace(primaryId)) _lights.Add(primaryId, _primary);
|
||||||
_lightOff = lightOff;
|
foreach (var light in options.Value.AdditionalSleepLights)
|
||||||
_safety = safety;
|
{
|
||||||
_time = time;
|
if (string.IsNullOrWhiteSpace(light.Key) || string.IsNullOrWhiteSpace(light.Name) ||
|
||||||
|
!light.EntityId.StartsWith("light.", StringComparison.Ordinal))
|
||||||
|
throw new InvalidOperationException("额外场景灯需要有效的模块键、名称和 light 实体");
|
||||||
|
if (!_lights.TryAdd(light.EntityId, new(gateway, lightOff, options, logger, safety, time, light)))
|
||||||
|
throw new InvalidOperationException($"场景灯实体重复配置:{light.EntityId}");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public Task StartAsync(CancellationToken cancellationToken) => Task.CompletedTask;
|
public ILightTransitionRuntime ForEntity(string entityId)
|
||||||
|
=> _lights.TryGetValue(entityId, out var light) ? light
|
||||||
public async Task StopAsync(CancellationToken cancellationToken)
|
: entityId == _primary.GetStatus().EntityId ? _primary
|
||||||
{
|
: throw new InvalidOperationException("该灯未配置为场景灯");
|
||||||
await _operationLock.WaitAsync(cancellationToken);
|
|
||||||
try { await StopCurrentAsync(); }
|
|
||||||
finally { _operationLock.Release(); }
|
|
||||||
}
|
|
||||||
|
|
||||||
public HomeAssistantLightTransitionOutput GetStatus()
|
public HomeAssistantLightTransitionOutput GetStatus()
|
||||||
{
|
{
|
||||||
lock (_statusLock)
|
var output = _primary.GetStatus();
|
||||||
{
|
output.Lights = _lights.Values.Select(light => light.GetStatus()).ToList();
|
||||||
return new HomeAssistantLightTransitionOutput
|
return output;
|
||||||
{
|
|
||||||
Success = _status.Success,
|
|
||||||
Running = _status.Running,
|
|
||||||
Mode = _status.Mode,
|
|
||||||
BrightnessPercent = _status.BrightnessPercent,
|
|
||||||
TargetPercent = _status.TargetPercent,
|
|
||||||
Message = _status.Message
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<HomeAssistantLightTransitionOutput> StartSleepAsync(int colorTemperatureKelvin, CancellationToken cancellationToken)
|
// 旧助眠/晨起接口只作用于原卧室灯;额外灯由各自场景模块调用。
|
||||||
|
public async Task<HomeAssistantLightTransitionOutput> StartSleepAsync(int kelvin, CancellationToken token)
|
||||||
{
|
{
|
||||||
await _operationLock.WaitAsync(cancellationToken);
|
await _primary.StartSleepAsync(kelvin, token);
|
||||||
try
|
return GetStatus();
|
||||||
{
|
}
|
||||||
await StopCurrentAsync();
|
public async Task<HomeAssistantLightTransitionOutput> StartWakeAsync(CancellationToken token)
|
||||||
await ReadAvailableLightAsync(cancellationToken);
|
{
|
||||||
await _homeAssistant.CallServiceAsync("light", "turn_on", new Dictionary<string, object?>
|
await _primary.StartWakeAsync(token);
|
||||||
{
|
return GetStatus();
|
||||||
["entity_id"] = LightEntityId,
|
}
|
||||||
["brightness_pct"] = SleepStartPercent,
|
public async Task<HomeAssistantLightTransitionOutput> CancelPrimaryAsync(CancellationToken token)
|
||||||
["color_temp_kelvin"] = Math.Clamp(colorTemperatureKelvin, 2200, 5000)
|
{
|
||||||
}, cancellationToken);
|
await _primary.CancelAsync(token);
|
||||||
|
return GetStatus();
|
||||||
_safety.MarkOn();
|
|
||||||
SetStatus(true, true, "sleep", SleepStartPercent, 0, "助眠渐暗已启动,每5秒降低1%亮度");
|
|
||||||
Launch("sleep", SleepStartPercent);
|
|
||||||
return GetStatus();
|
|
||||||
}
|
|
||||||
finally { _operationLock.Release(); }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<HomeAssistantLightTransitionOutput> StartWakeAsync(CancellationToken cancellationToken)
|
// 一键关闭/场景替换必须取消全部灯的计时任务,防止后续 tick 重新点亮灯。
|
||||||
|
public async Task<HomeAssistantLightTransitionOutput> CancelAsync(CancellationToken token)
|
||||||
{
|
{
|
||||||
await _operationLock.WaitAsync(cancellationToken);
|
foreach (var light in _lights.Values) await light.CancelAsync(token);
|
||||||
try
|
if (_lights.Count == 0) await _primary.CancelAsync(token);
|
||||||
{
|
return GetStatus();
|
||||||
await StopCurrentAsync();
|
|
||||||
var light = await ReadAvailableLightAsync(cancellationToken);
|
|
||||||
var knownOffAt = _safety.OffAt;
|
|
||||||
var isOff = string.Equals(light.State, "off", StringComparison.OrdinalIgnoreCase) ||
|
|
||||||
(knownOffAt is not null && (light.LastUpdated is null || light.LastUpdated <= knownOffAt));
|
|
||||||
var brightness = light.GetDoubleAttribute("brightness");
|
|
||||||
if (!isOff && brightness is null)
|
|
||||||
throw new InvalidOperationException("灯具未返回当前亮度,无法从当前亮度启动晨起唤醒");
|
|
||||||
var current = isOff ? 1 : Math.Clamp((int)Math.Round(brightness!.Value / 255d * 100), 1, 100);
|
|
||||||
|
|
||||||
if (current >= WakeTargetPercent && !isOff)
|
|
||||||
{
|
|
||||||
_safety.MarkOn();
|
|
||||||
SetStatus(true, false, "wake", current, WakeTargetPercent, "当前亮度已达到70%或更高,保持原亮度");
|
|
||||||
return GetStatus();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isOff)
|
|
||||||
{
|
|
||||||
await _homeAssistant.CallServiceAsync("light", "turn_on", new Dictionary<string, object?>
|
|
||||||
{
|
|
||||||
["entity_id"] = LightEntityId,
|
|
||||||
["brightness_pct"] = 1
|
|
||||||
}, cancellationToken);
|
|
||||||
}
|
|
||||||
|
|
||||||
_safety.MarkOn();
|
|
||||||
SetStatus(true, true, "wake", current, WakeTargetPercent, "晨起唤醒已启动,每5秒增加1%亮度");
|
|
||||||
Launch("wake", current);
|
|
||||||
return GetStatus();
|
|
||||||
}
|
|
||||||
finally { _operationLock.Release(); }
|
|
||||||
}
|
}
|
||||||
|
public Task StartAsync(CancellationToken token) => Task.CompletedTask;
|
||||||
public async Task<HomeAssistantLightTransitionOutput> CancelAsync(CancellationToken cancellationToken)
|
public async Task StopAsync(CancellationToken token)
|
||||||
{
|
{
|
||||||
await _operationLock.WaitAsync(cancellationToken);
|
foreach (var light in _lights.Values) await light.StopAsync(token);
|
||||||
try
|
if (_lights.Count == 0) await _primary.StopAsync(token);
|
||||||
{
|
|
||||||
await StopCurrentAsync();
|
|
||||||
var brightness = GetStatus().BrightnessPercent;
|
|
||||||
SetStatus(true, false, "none", brightness, 0, "灯光渐变已停止");
|
|
||||||
return GetStatus();
|
|
||||||
}
|
|
||||||
finally { _operationLock.Release(); }
|
|
||||||
}
|
|
||||||
|
|
||||||
private string LightEntityId => !string.IsNullOrWhiteSpace(_options.Entities.AmbientLight)
|
|
||||||
? _options.Entities.AmbientLight
|
|
||||||
: throw new InvalidOperationException("卧室灯光实体尚未配置");
|
|
||||||
|
|
||||||
private async Task<HomeAssistantState> ReadAvailableLightAsync(CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
var light = (await _homeAssistant.GetStatesAsync(cancellationToken))
|
|
||||||
.FirstOrDefault(item => string.Equals(item.EntityId, LightEntityId, StringComparison.OrdinalIgnoreCase));
|
|
||||||
if (light is null || light.State is "unknown" or "unavailable")
|
|
||||||
throw new InvalidOperationException("卧室灯光当前离线或不可用");
|
|
||||||
return light;
|
|
||||||
}
|
|
||||||
|
|
||||||
private void Launch(string mode, int initialPercent)
|
|
||||||
{
|
|
||||||
_activeCancellation = new CancellationTokenSource();
|
|
||||||
_activeTask = RunAsync(mode, initialPercent, _activeCancellation.Token);
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task RunAsync(string mode, int initialPercent, CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
var current = initialPercent;
|
|
||||||
try
|
|
||||||
{
|
|
||||||
while (true)
|
|
||||||
{
|
|
||||||
await Task.Delay(StepInterval, _time, cancellationToken);
|
|
||||||
var light = await ReadAvailableLightAsync(cancellationToken);
|
|
||||||
if (!string.Equals(light.State, "on", StringComparison.OrdinalIgnoreCase))
|
|
||||||
{
|
|
||||||
_safety.MarkOff(_time.GetUtcNow());
|
|
||||||
if (mode == "sleep")
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
await _lightOff.NotifyAsync(cancellationToken);
|
|
||||||
SetStatus(true, false, mode, 0, 0, "灯已关闭,床垫按摩已停止");
|
|
||||||
}
|
|
||||||
catch (Exception exception) when (exception is not OperationCanceledException)
|
|
||||||
{
|
|
||||||
_logger.LogWarning(exception, "灯已关闭,但床垫按摩停止失败");
|
|
||||||
SetStatus(false, false, mode, 0, 0, exception.Message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else SetStatus(false, false, mode, 0, WakeTargetPercent, "灯已从其他位置关闭,渐变已停止");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
var brightness = light.GetDoubleAttribute("brightness");
|
|
||||||
if (brightness is null)
|
|
||||||
{
|
|
||||||
SetStatus(false, false, mode, current, mode == "wake" ? WakeTargetPercent : 0, "灯具未返回当前亮度,渐变已停止");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
var observed = Math.Clamp((int)Math.Round(brightness.Value / 255d * 100), 1, 100);
|
|
||||||
if (Math.Abs(observed - current) > 2)
|
|
||||||
{
|
|
||||||
SetStatus(false, false, mode, observed, mode == "wake" ? WakeTargetPercent : 0, "灯光亮度已从其他位置修改,渐变已停止");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (mode == "sleep" && current == 1)
|
|
||||||
{
|
|
||||||
await _homeAssistant.CallServiceAsync("light", "turn_off", new Dictionary<string, object?>
|
|
||||||
{
|
|
||||||
["entity_id"] = LightEntityId
|
|
||||||
}, cancellationToken);
|
|
||||||
_safety.MarkOff(_time.GetUtcNow());
|
|
||||||
SetStatus(true, false, mode, 0, 0, "灯光已关闭,正在停止床垫按摩");
|
|
||||||
try
|
|
||||||
{
|
|
||||||
await _lightOff.NotifyAsync(cancellationToken);
|
|
||||||
SetStatus(true, false, mode, 0, 0, "助眠渐暗已完成,灯光与床垫按摩已关闭");
|
|
||||||
}
|
|
||||||
catch (Exception exception) when (exception is not OperationCanceledException)
|
|
||||||
{
|
|
||||||
_logger.LogWarning(exception, "助眠渐暗已完成,但床垫按摩停止失败");
|
|
||||||
SetStatus(false, false, mode, 0, 0, exception.Message);
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
var next = mode == "sleep" ? current - 1 : current + 1;
|
|
||||||
await _homeAssistant.CallServiceAsync("light", "turn_on", new Dictionary<string, object?>
|
|
||||||
{
|
|
||||||
["entity_id"] = LightEntityId,
|
|
||||||
["brightness_pct"] = next
|
|
||||||
}, cancellationToken);
|
|
||||||
current = next;
|
|
||||||
var finished = mode == "wake" && current >= WakeTargetPercent;
|
|
||||||
SetStatus(true, !finished, mode, current, mode == "wake" ? WakeTargetPercent : 0,
|
|
||||||
finished ? "晨起唤醒已完成,灯光保持70%亮度" : mode == "wake" ? "晨起唤醒进行中" : "助眠渐暗进行中");
|
|
||||||
if (finished) return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) { }
|
|
||||||
catch (Exception exception)
|
|
||||||
{
|
|
||||||
_logger.LogWarning(exception, "卧室灯光渐变任务中断");
|
|
||||||
SetStatus(false, false, mode, current, mode == "wake" ? WakeTargetPercent : 0,
|
|
||||||
$"灯光渐变中断:{exception.Message}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task StopCurrentAsync()
|
|
||||||
{
|
|
||||||
if (_activeCancellation is null) return;
|
|
||||||
_activeCancellation.Cancel();
|
|
||||||
if (_activeTask is not null) await _activeTask;
|
|
||||||
_activeCancellation.Dispose();
|
|
||||||
_activeCancellation = null;
|
|
||||||
_activeTask = null;
|
|
||||||
SetStatus(true, false, "none", GetStatus().BrightnessPercent, 0, "灯光渐变已停止");
|
|
||||||
}
|
|
||||||
|
|
||||||
private void SetStatus(bool success, bool running, string mode, int brightness, int target, string message)
|
|
||||||
{
|
|
||||||
lock (_statusLock)
|
|
||||||
{
|
|
||||||
_status = new HomeAssistantLightTransitionOutput
|
|
||||||
{
|
|
||||||
Success = success,
|
|
||||||
Running = running,
|
|
||||||
Mode = mode,
|
|
||||||
BrightnessPercent = brightness,
|
|
||||||
TargetPercent = target,
|
|
||||||
Message = message
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+10
@@ -10,6 +10,16 @@ public sealed class HomeAssistantOptions : IConfigurableOptions
|
|||||||
public int TimeoutSeconds { get; set; } = 15;
|
public int TimeoutSeconds { get; set; } = 15;
|
||||||
public HomeAssistantEntityOptions Entities { get; set; } = new();
|
public HomeAssistantEntityOptions Entities { get; set; } = new();
|
||||||
public HomeAssistantSleepSceneOptions SleepScene { get; set; } = new();
|
public HomeAssistantSleepSceneOptions SleepScene { get; set; } = new();
|
||||||
|
public List<SceneLightOptions> AdditionalSleepLights { get; set; } = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>明确加入舒睡/全关的额外灯具;未配置的灯仍仅提供单独控制。</summary>
|
||||||
|
public sealed class SceneLightOptions
|
||||||
|
{
|
||||||
|
public string Key { get; set; } = string.Empty;
|
||||||
|
public string Name { get; set; } = string.Empty;
|
||||||
|
public string EntityId { get; set; } = string.Empty;
|
||||||
|
public string? SleepEffect { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
public sealed class HomeAssistantEntityOptions
|
public sealed class HomeAssistantEntityOptions
|
||||||
|
|||||||
+313
@@ -0,0 +1,313 @@
|
|||||||
|
using Evergreen.Health.Application.Health.Models;
|
||||||
|
using Evergreen.Health.Application.Health.Scenes;
|
||||||
|
using Evergreen.Health.Application.Health.Devices.Lighting;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
using System.Text.Json;
|
||||||
|
|
||||||
|
namespace Evergreen.Health.Application.Health.Integrations.HomeAssistant;
|
||||||
|
|
||||||
|
/// <summary>单灯渐变执行器;每盏灯的计时、取消和状态互相隔离。</summary>
|
||||||
|
internal sealed class LightTransitionWorker : ILightTransitionRuntime
|
||||||
|
{
|
||||||
|
private const int SleepStartPercent = 50;
|
||||||
|
private const int WakeTargetPercent = 70;
|
||||||
|
private static readonly TimeSpan StepInterval = TimeSpan.FromSeconds(5);
|
||||||
|
|
||||||
|
private readonly IHomeAssistantGateway _homeAssistant;
|
||||||
|
private readonly LightOffDispatcher _lightOff;
|
||||||
|
private readonly SceneLightSafetyState _safety;
|
||||||
|
private readonly TimeProvider _time;
|
||||||
|
private readonly string _entityId;
|
||||||
|
private readonly string? _sleepEffect;
|
||||||
|
private readonly bool _controlsMassage;
|
||||||
|
private readonly ILogger<HomeAssistantLightTransitionService> _logger;
|
||||||
|
private readonly SemaphoreSlim _operationLock = new(1, 1);
|
||||||
|
private readonly object _statusLock = new();
|
||||||
|
private CancellationTokenSource? _activeCancellation;
|
||||||
|
private Task? _activeTask;
|
||||||
|
private HomeAssistantLightTransitionOutput _status = new();
|
||||||
|
|
||||||
|
public LightTransitionWorker(
|
||||||
|
IHomeAssistantGateway homeAssistant,
|
||||||
|
LightOffDispatcher lightOff,
|
||||||
|
IOptions<HomeAssistantOptions> options,
|
||||||
|
ILogger<HomeAssistantLightTransitionService> logger,
|
||||||
|
SceneLightSafetyState safety,
|
||||||
|
TimeProvider time, SceneLightOptions? light = null)
|
||||||
|
{
|
||||||
|
_entityId = light?.EntityId ?? options.Value.Entities.AmbientLight;
|
||||||
|
_sleepEffect = light?.SleepEffect;
|
||||||
|
_controlsMassage = light is null;
|
||||||
|
_logger = logger;
|
||||||
|
_homeAssistant = homeAssistant;
|
||||||
|
_lightOff = lightOff;
|
||||||
|
_safety = safety;
|
||||||
|
_time = time;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task StopAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
await _operationLock.WaitAsync(cancellationToken);
|
||||||
|
try { await StopCurrentAsync(); }
|
||||||
|
finally { _operationLock.Release(); }
|
||||||
|
}
|
||||||
|
|
||||||
|
public HomeAssistantLightTransitionOutput GetStatus()
|
||||||
|
{
|
||||||
|
lock (_statusLock)
|
||||||
|
{
|
||||||
|
return new HomeAssistantLightTransitionOutput
|
||||||
|
{
|
||||||
|
EntityId = _entityId,
|
||||||
|
Success = _status.Success,
|
||||||
|
Running = _status.Running,
|
||||||
|
Mode = _status.Mode,
|
||||||
|
BrightnessPercent = _status.BrightnessPercent,
|
||||||
|
TargetPercent = _status.TargetPercent,
|
||||||
|
Message = _status.Message
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<HomeAssistantLightTransitionOutput> StartSleepAsync(int colorTemperatureKelvin, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
await _operationLock.WaitAsync(cancellationToken);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await StopCurrentAsync();
|
||||||
|
var light = await ReadAvailableLightAsync(cancellationToken);
|
||||||
|
var data = BuildSleepStartData(light, colorTemperatureKelvin);
|
||||||
|
await _homeAssistant.CallServiceAsync("light", "turn_on", data, cancellationToken);
|
||||||
|
|
||||||
|
if (_controlsMassage) _safety.MarkOn();
|
||||||
|
SetStatus(true, true, "sleep", SleepStartPercent, 0, "助眠渐暗已启动,每5秒降低1%亮度");
|
||||||
|
Launch("sleep", SleepStartPercent);
|
||||||
|
return GetStatus();
|
||||||
|
}
|
||||||
|
finally { _operationLock.Release(); }
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<HomeAssistantLightTransitionOutput> StartWakeAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
await _operationLock.WaitAsync(cancellationToken);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await StopCurrentAsync();
|
||||||
|
var light = await ReadAvailableLightAsync(cancellationToken);
|
||||||
|
var knownOffAt = _controlsMassage ? _safety.OffAt : null;
|
||||||
|
var isOff = string.Equals(light.State, "off", StringComparison.OrdinalIgnoreCase) ||
|
||||||
|
(knownOffAt is not null && (light.LastUpdated is null || light.LastUpdated <= knownOffAt));
|
||||||
|
var brightness = light.GetDoubleAttribute("brightness");
|
||||||
|
if (!isOff && brightness is null)
|
||||||
|
throw new InvalidOperationException("灯具未返回当前亮度,无法从当前亮度启动晨起唤醒");
|
||||||
|
var current = isOff ? 1 : Math.Clamp((int)Math.Round(brightness!.Value / 255d * 100), 1, 100);
|
||||||
|
|
||||||
|
if (current >= WakeTargetPercent && !isOff)
|
||||||
|
{
|
||||||
|
if (_controlsMassage) _safety.MarkOn();
|
||||||
|
SetStatus(true, false, "wake", current, WakeTargetPercent, "当前亮度已达到70%或更高,保持原亮度");
|
||||||
|
return GetStatus();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isOff)
|
||||||
|
{
|
||||||
|
await _homeAssistant.CallServiceAsync("light", "turn_on", new Dictionary<string, object?>
|
||||||
|
{
|
||||||
|
["entity_id"] = LightEntityId,
|
||||||
|
["brightness_pct"] = 1
|
||||||
|
}, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_controlsMassage) _safety.MarkOn();
|
||||||
|
SetStatus(true, true, "wake", current, WakeTargetPercent, "晨起唤醒已启动,每5秒增加1%亮度");
|
||||||
|
Launch("wake", current);
|
||||||
|
return GetStatus();
|
||||||
|
}
|
||||||
|
finally { _operationLock.Release(); }
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<HomeAssistantLightTransitionOutput> CancelAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
await _operationLock.WaitAsync(cancellationToken);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await StopCurrentAsync();
|
||||||
|
var brightness = GetStatus().BrightnessPercent;
|
||||||
|
SetStatus(true, false, "none", brightness, 0, "灯光渐变已停止");
|
||||||
|
return GetStatus();
|
||||||
|
}
|
||||||
|
finally { _operationLock.Release(); }
|
||||||
|
}
|
||||||
|
|
||||||
|
private string LightEntityId => !string.IsNullOrWhiteSpace(_entityId)
|
||||||
|
? _entityId
|
||||||
|
: throw new InvalidOperationException("卧室灯光实体尚未配置");
|
||||||
|
|
||||||
|
private Dictionary<string, object?> BuildSleepStartData(HomeAssistantState state, int kelvin)
|
||||||
|
{
|
||||||
|
var data = new Dictionary<string, object?> { ["entity_id"] = LightEntityId, ["brightness_pct"] = SleepStartPercent };
|
||||||
|
// 原绑定灯兼容旧 HA 能力回读;新场景灯必须经过同一套标准能力校验。
|
||||||
|
JsonElement modes = default;
|
||||||
|
var hasModes = state.Attributes.ValueKind == JsonValueKind.Object &&
|
||||||
|
state.Attributes.TryGetProperty("supported_color_modes", out modes) && modes.ValueKind == JsonValueKind.Array;
|
||||||
|
if (_controlsMassage && !hasModes)
|
||||||
|
{
|
||||||
|
data["color_temp_kelvin"] = Math.Clamp(kelvin, 2200, 5000);
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
var device = new HomeAssistantDiscoveredDeviceOutput
|
||||||
|
{
|
||||||
|
PrimaryControlEntityId = LightEntityId,
|
||||||
|
Entities = [new() { EntityId = LightEntityId, Domain = "light" }]
|
||||||
|
};
|
||||||
|
var controls = new StandardLightControl();
|
||||||
|
controls.Build(device, state, "light_brightness", SleepStartPercent);
|
||||||
|
if (hasModes && modes.EnumerateArray().Any(mode => mode.ValueKind == JsonValueKind.String && mode.GetString() == "color_temp"))
|
||||||
|
{
|
||||||
|
var min = state.GetDoubleAttribute("min_color_temp_kelvin");
|
||||||
|
var max = state.GetDoubleAttribute("max_color_temp_kelvin");
|
||||||
|
if (min is null || max is null || min <= 0 || max <= min || max > int.MaxValue ||
|
||||||
|
min != Math.Truncate(min.Value) || max != Math.Truncate(max.Value))
|
||||||
|
throw new InvalidOperationException("场景灯色温范围未确认,未发送启动指令");
|
||||||
|
var target = Math.Clamp(kelvin, (int)min.Value, (int)max.Value);
|
||||||
|
controls.Build(device, state, "light_color_temperature", target);
|
||||||
|
data["color_temp_kelvin"] = target;
|
||||||
|
}
|
||||||
|
if (!string.IsNullOrWhiteSpace(_sleepEffect))
|
||||||
|
{
|
||||||
|
controls.Build(device, state, "light_effect", _sleepEffect);
|
||||||
|
data["effect"] = _sleepEffect;
|
||||||
|
}
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<HomeAssistantState> ReadAvailableLightAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var light = (await _homeAssistant.GetStatesAsync(cancellationToken))
|
||||||
|
.FirstOrDefault(item => string.Equals(item.EntityId, LightEntityId, StringComparison.OrdinalIgnoreCase));
|
||||||
|
if (light is null || light.State is not ("on" or "off"))
|
||||||
|
throw new InvalidOperationException("卧室灯光当前离线或不可用");
|
||||||
|
return light;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void Launch(string mode, int initialPercent)
|
||||||
|
{
|
||||||
|
_activeCancellation = new CancellationTokenSource();
|
||||||
|
_activeTask = RunAsync(mode, initialPercent, _activeCancellation.Token);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task RunAsync(string mode, int initialPercent, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var current = initialPercent;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
while (true)
|
||||||
|
{
|
||||||
|
await Task.Delay(StepInterval, _time, cancellationToken);
|
||||||
|
var light = await ReadAvailableLightAsync(cancellationToken);
|
||||||
|
if (!string.Equals(light.State, "on", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
if (_controlsMassage) _safety.MarkOff(_time.GetUtcNow());
|
||||||
|
if (mode == "sleep" && _controlsMassage)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await _lightOff.NotifyAsync(cancellationToken);
|
||||||
|
SetStatus(true, false, mode, 0, 0, "灯已关闭,床垫按摩已停止");
|
||||||
|
}
|
||||||
|
catch (Exception exception) when (exception is not OperationCanceledException)
|
||||||
|
{
|
||||||
|
_logger.LogWarning(exception, "灯已关闭,但床垫按摩停止失败");
|
||||||
|
SetStatus(false, false, mode, 0, 0, exception.Message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else SetStatus(true, false, mode, 0, mode == "wake" ? WakeTargetPercent : 0, "灯已从其他位置关闭,渐变已停止");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var brightness = light.GetDoubleAttribute("brightness");
|
||||||
|
if (brightness is null)
|
||||||
|
{
|
||||||
|
SetStatus(false, false, mode, current, mode == "wake" ? WakeTargetPercent : 0, "灯具未返回当前亮度,渐变已停止");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var observed = Math.Clamp((int)Math.Round(brightness.Value / 255d * 100), 1, 100);
|
||||||
|
if (Math.Abs(observed - current) > 2)
|
||||||
|
{
|
||||||
|
SetStatus(false, false, mode, observed, mode == "wake" ? WakeTargetPercent : 0, "灯光亮度已从其他位置修改,渐变已停止");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mode == "sleep" && current == 1)
|
||||||
|
{
|
||||||
|
await _homeAssistant.CallServiceAsync("light", "turn_off", new Dictionary<string, object?>
|
||||||
|
{
|
||||||
|
["entity_id"] = LightEntityId
|
||||||
|
}, cancellationToken);
|
||||||
|
if (_controlsMassage) _safety.MarkOff(_time.GetUtcNow());
|
||||||
|
SetStatus(true, false, mode, 0, 0, _controlsMassage ? "灯光已关闭,正在停止床垫按摩" : "助眠渐暗已完成,灯光已关闭");
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (_controlsMassage) await _lightOff.NotifyAsync(cancellationToken);
|
||||||
|
SetStatus(true, false, mode, 0, 0, _controlsMassage ? "助眠渐暗已完成,灯光与床垫按摩已关闭" : "助眠渐暗已完成,灯光已关闭");
|
||||||
|
}
|
||||||
|
catch (Exception exception) when (exception is not OperationCanceledException)
|
||||||
|
{
|
||||||
|
_logger.LogWarning(exception, "助眠渐暗已完成,但床垫按摩停止失败");
|
||||||
|
SetStatus(false, false, mode, 0, 0, exception.Message);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var next = mode == "sleep" ? current - 1 : current + 1;
|
||||||
|
await _homeAssistant.CallServiceAsync("light", "turn_on", new Dictionary<string, object?>
|
||||||
|
{
|
||||||
|
["entity_id"] = LightEntityId,
|
||||||
|
["brightness_pct"] = next
|
||||||
|
}, cancellationToken);
|
||||||
|
current = next;
|
||||||
|
var finished = mode == "wake" && current >= WakeTargetPercent;
|
||||||
|
SetStatus(true, !finished, mode, current, mode == "wake" ? WakeTargetPercent : 0,
|
||||||
|
finished ? "晨起唤醒已完成,灯光保持70%亮度" : mode == "wake" ? "晨起唤醒进行中" : "助眠渐暗进行中");
|
||||||
|
if (finished) return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) { }
|
||||||
|
catch (Exception exception)
|
||||||
|
{
|
||||||
|
_logger.LogWarning(exception, "卧室灯光渐变任务中断");
|
||||||
|
SetStatus(false, false, mode, current, mode == "wake" ? WakeTargetPercent : 0,
|
||||||
|
$"灯光渐变中断:{exception.Message}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task StopCurrentAsync()
|
||||||
|
{
|
||||||
|
if (_activeCancellation is null) return;
|
||||||
|
_activeCancellation.Cancel();
|
||||||
|
if (_activeTask is not null) await _activeTask;
|
||||||
|
_activeCancellation.Dispose();
|
||||||
|
_activeCancellation = null;
|
||||||
|
_activeTask = null;
|
||||||
|
SetStatus(true, false, "none", GetStatus().BrightnessPercent, 0, "灯光渐变已停止");
|
||||||
|
}
|
||||||
|
|
||||||
|
private void SetStatus(bool success, bool running, string mode, int brightness, int target, string message)
|
||||||
|
{
|
||||||
|
lock (_statusLock)
|
||||||
|
{
|
||||||
|
_status = new HomeAssistantLightTransitionOutput
|
||||||
|
{
|
||||||
|
EntityId = _entityId,
|
||||||
|
Success = success,
|
||||||
|
Running = running,
|
||||||
|
Mode = mode,
|
||||||
|
BrightnessPercent = brightness,
|
||||||
|
TargetPercent = target,
|
||||||
|
Message = message
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -38,6 +38,7 @@ public sealed class HomeAssistantDiscoveredDeviceOutput
|
|||||||
public string Category { get; set; } = nameof(HealthDeviceCategory.Other);
|
public string Category { get; set; } = nameof(HealthDeviceCategory.Other);
|
||||||
public bool Online { get; set; }
|
public bool Online { get; set; }
|
||||||
public string? PrimaryControlEntityId { get; set; }
|
public string? PrimaryControlEntityId { get; set; }
|
||||||
|
public string? DefaultLightEffect { get; set; }
|
||||||
public List<HomeAssistantEntityOutput> Entities { get; set; } = [];
|
public List<HomeAssistantEntityOutput> Entities { get; set; } = [];
|
||||||
public Dictionary<string, object?> Metrics { get; set; } = [];
|
public Dictionary<string, object?> Metrics { get; set; } = [];
|
||||||
}
|
}
|
||||||
@@ -120,12 +121,14 @@ public sealed class HomeAssistantDeviceSwitchOutput
|
|||||||
|
|
||||||
public sealed class HomeAssistantLightTransitionOutput
|
public sealed class HomeAssistantLightTransitionOutput
|
||||||
{
|
{
|
||||||
|
public string EntityId { get; set; } = string.Empty;
|
||||||
public bool Success { get; set; } = true;
|
public bool Success { get; set; } = true;
|
||||||
public bool Running { get; set; }
|
public bool Running { get; set; }
|
||||||
public string Mode { get; set; } = "none";
|
public string Mode { get; set; } = "none";
|
||||||
public int BrightnessPercent { get; set; }
|
public int BrightnessPercent { get; set; }
|
||||||
public int TargetPercent { get; set; }
|
public int TargetPercent { get; set; }
|
||||||
public string Message { get; set; } = string.Empty;
|
public string Message { get; set; } = string.Empty;
|
||||||
|
public List<HomeAssistantLightTransitionOutput> Lights { get; set; } = [];
|
||||||
}
|
}
|
||||||
|
|
||||||
public sealed class HomeAssistantSetParameterInput
|
public sealed class HomeAssistantSetParameterInput
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ public sealed class HealthHomeAssistantService : IDynamicApiController, ITransie
|
|||||||
private readonly MattressComfortService _mattressComfort;
|
private readonly MattressComfortService _mattressComfort;
|
||||||
private readonly IHttpContextAccessor _httpContextAccessor;
|
private readonly IHttpContextAccessor _httpContextAccessor;
|
||||||
private readonly DeviceModuleRegistry _modules;
|
private readonly DeviceModuleRegistry _modules;
|
||||||
|
private readonly DeviceParameterController _parameters;
|
||||||
private readonly HealthSceneCoordinator _scenes;
|
private readonly HealthSceneCoordinator _scenes;
|
||||||
private readonly HomeAssistantDeviceCatalog _catalog;
|
private readonly HomeAssistantDeviceCatalog _catalog;
|
||||||
private readonly ILogger<HealthHomeAssistantService> _logger;
|
private readonly ILogger<HealthHomeAssistantService> _logger;
|
||||||
@@ -48,6 +49,7 @@ public sealed class HealthHomeAssistantService : IDynamicApiController, ITransie
|
|||||||
MattressComfortService mattressComfort,
|
MattressComfortService mattressComfort,
|
||||||
IHttpContextAccessor httpContextAccessor,
|
IHttpContextAccessor httpContextAccessor,
|
||||||
DeviceModuleRegistry modules,
|
DeviceModuleRegistry modules,
|
||||||
|
DeviceParameterController parameters,
|
||||||
HealthSceneCoordinator scenes,
|
HealthSceneCoordinator scenes,
|
||||||
HomeAssistantDeviceCatalog catalog,
|
HomeAssistantDeviceCatalog catalog,
|
||||||
ILogger<HealthHomeAssistantService> logger)
|
ILogger<HealthHomeAssistantService> logger)
|
||||||
@@ -62,6 +64,7 @@ public sealed class HealthHomeAssistantService : IDynamicApiController, ITransie
|
|||||||
_mattressComfort = mattressComfort;
|
_mattressComfort = mattressComfort;
|
||||||
_httpContextAccessor = httpContextAccessor;
|
_httpContextAccessor = httpContextAccessor;
|
||||||
_modules = modules;
|
_modules = modules;
|
||||||
|
_parameters = parameters;
|
||||||
_scenes = scenes;
|
_scenes = scenes;
|
||||||
_catalog = catalog;
|
_catalog = catalog;
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
@@ -95,7 +98,7 @@ public sealed class HealthHomeAssistantService : IDynamicApiController, ITransie
|
|||||||
[HttpPost("/api/health/home-assistant/lights/stop")]
|
[HttpPost("/api/health/home-assistant/lights/stop")]
|
||||||
public async Task<HomeAssistantLightTransitionOutput> StopLightTransition(CancellationToken cancellationToken)
|
public async Task<HomeAssistantLightTransitionOutput> StopLightTransition(CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
try { return await _lightTransition.CancelAsync(cancellationToken); }
|
try { return await _lightTransition.CancelPrimaryAsync(cancellationToken); }
|
||||||
catch (Exception exception) when (exception is not OperationCanceledException) { return new HomeAssistantLightTransitionOutput { Success = false, Message = exception.Message }; }
|
catch (Exception exception) when (exception is not OperationCanceledException) { return new HomeAssistantLightTransitionOutput { Success = false, Message = exception.Message }; }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -303,20 +306,8 @@ public sealed class HealthHomeAssistantService : IDynamicApiController, ITransie
|
|||||||
}
|
}
|
||||||
|
|
||||||
var parameterType = input.ParameterType.Trim().ToLowerInvariant();
|
var parameterType = input.ParameterType.Trim().ToLowerInvariant();
|
||||||
var module = _modules.ForParameter(parameterType);
|
var command = await _parameters.ExecuteAsync(targetDevice, stateMap, parameterType, input.Value,
|
||||||
var command = module.BuildParameterCommand(parameterType, input.Value);
|
input.EntityId, cancellationToken);
|
||||||
if (string.IsNullOrWhiteSpace(command.EntityId))
|
|
||||||
throw new InvalidOperationException("该参数尚未配置 Home Assistant 实体");
|
|
||||||
if (!string.IsNullOrWhiteSpace(input.EntityId) &&
|
|
||||||
!string.Equals(input.EntityId, command.EntityId, StringComparison.OrdinalIgnoreCase))
|
|
||||||
throw new InvalidOperationException("请求实体与该参数的受信任实体不匹配");
|
|
||||||
if (!targetDevice.Entities.Any(entity => string.Equals(entity.EntityId, command.EntityId, StringComparison.OrdinalIgnoreCase)))
|
|
||||||
throw new InvalidOperationException("该参数不属于指定设备");
|
|
||||||
// 外部音频网关不依赖 HA 媒体实体是否在线,但仍校验实体归属。
|
|
||||||
if (!module.ParameterUsesIndependentGateway(command) &&
|
|
||||||
(!stateMap.TryGetValue(command.EntityId, out var state) || !IsAvailable(state)))
|
|
||||||
throw new InvalidOperationException("目标参数实体当前离线或不可用");
|
|
||||||
await module.ExecuteParameterAsync(command, cancellationToken);
|
|
||||||
return new HomeAssistantSetParameterOutput
|
return new HomeAssistantSetParameterOutput
|
||||||
{
|
{
|
||||||
Success = true, DeviceId = input.DeviceId, ParameterType = parameterType,
|
Success = true, DeviceId = input.DeviceId, ParameterType = parameterType,
|
||||||
|
|||||||
Reference in New Issue
Block a user