11
This commit is contained in:
@@ -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,
|
||||
Online = isOnline,
|
||||
PrimaryControlEntityId = primaryControlEntityId,
|
||||
DefaultLightEffect = DefaultLightEffect(primaryControlEntityId),
|
||||
Entities = entityOutputs,
|
||||
Metrics = metrics
|
||||
});
|
||||
@@ -125,12 +126,16 @@ public sealed class HomeAssistantDeviceCatalog
|
||||
DeviceId = $"fallback_{module.Key}", ModuleKey = module.Key, Name = module.DisplayName,
|
||||
Category = module.Category, Online = entityOutputs.Any(entity => entity.Available),
|
||||
PrimaryControlEntityId = FindPrimaryControlEntityId(module.Category, entityOutputs),
|
||||
DefaultLightEffect = DefaultLightEffect(module.PrimaryEntityId),
|
||||
Entities = entityOutputs, Metrics = ExtractDeviceMetrics(module.Category, entityOutputs)
|
||||
});
|
||||
}
|
||||
return list;
|
||||
}
|
||||
|
||||
private string? DefaultLightEffect(string? entityId) => _options.AdditionalSleepLights.FirstOrDefault(light =>
|
||||
string.Equals(light.EntityId, entityId, StringComparison.OrdinalIgnoreCase))?.SleepEffect;
|
||||
|
||||
private string? FindPrimaryControlEntityId(
|
||||
string category,
|
||||
IReadOnlyList<HomeAssistantEntityOutput> entities)
|
||||
@@ -301,6 +306,7 @@ public sealed class HomeAssistantDeviceCatalog
|
||||
"friendly_name", "unit_of_measurement", "device_class",
|
||||
"current_humidity", "humidity", "mode", "fan_mode", "percentage",
|
||||
"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",
|
||||
"battery_level", "temperature", "options", "min", "max", "step", "hvac_modes", "supported_features",
|
||||
"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<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<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<ILightOffHandler>(provider => provider.GetRequiredService<MattressModule>());
|
||||
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<HealthSceneCoordinator>();
|
||||
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)
|
||||
{
|
||||
// 标准手动面板复用本灯的专属安全钩子;其他灯不会触发助眠灯/床垫联动。
|
||||
command = command with { Intent = command.Service == "turn_off"
|
||||
? DeviceCommandIntent.TurnRelaxationLightOff : DeviceCommandIntent.TurnRelaxationLightOn };
|
||||
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.ExpectedState is not null) await Runner.VerifyParameterAsync(command, 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()
|
||||
: [];
|
||||
}
|
||||
Reference in New Issue
Block a user