This commit is contained in:
fuenmao
2026-10-09 16:46:30 +08:00
parent 4a634ef4c2
commit e6b90a06ab
63 changed files with 4170 additions and 1518 deletions
@@ -0,0 +1,160 @@
using Evergreen.Health.Application.Health.Devices.Contracts;
using Evergreen.Health.Application.Health.Integrations.HomeAssistant;
using System.Globalization;
namespace Evergreen.Health.Application.Health.Scenes;
/// <summary>共用发送、取消、回读和有限重试,任何家具都可复用。</summary>
public sealed class DeviceActionRunner
{
private readonly IHomeAssistantGateway _gateway;
private readonly TimeProvider _time;
private readonly DeviceExecutionOptions _options;
private readonly IReadOnlyList<IDeviceActionSafetyRule> _safety;
private readonly SemaphoreSlim _commandGate = new(1, 1);
public DeviceActionRunner(IHomeAssistantGateway gateway, TimeProvider time, DeviceExecutionOptions options,
IEnumerable<IDeviceActionSafetyRule>? safety = null)
=> (_gateway, _time, _options, _safety) = (gateway, time, options, safety?.ToArray() ?? []);
public static bool IsAvailable(HomeAssistantState state)
=> !string.IsNullOrWhiteSpace(state.State) &&
!string.Equals(state.State, "unavailable", StringComparison.OrdinalIgnoreCase) &&
(!string.Equals(state.State, "unknown", StringComparison.OrdinalIgnoreCase) ||
state.EntityId.StartsWith("button.", StringComparison.OrdinalIgnoreCase));
public async Task<HomeAssistantState> ReadAvailableStateAsync(string entityId, CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(entityId)) throw new InvalidOperationException("参数实体尚未配置");
var state = (await _gateway.GetStatesAsync(cancellationToken)).FirstOrDefault(item =>
string.Equals(item.EntityId, entityId, StringComparison.OrdinalIgnoreCase));
if (state is null || !IsAvailable(state)) throw new InvalidOperationException("目标参数实体当前离线或不可用");
return state;
}
public async Task SendAsync(DeviceCommand command, CancellationToken cancellationToken)
{
cancellationToken.ThrowIfCancellationRequested();
if (string.IsNullOrWhiteSpace(command.EntityId))
throw new InvalidOperationException("参数实体尚未配置");
await _commandGate.WaitAsync(cancellationToken);
try
{
using var timeout = new CancellationTokenSource(_options.CommandTimeout, _time);
using var bounded = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, timeout.Token);
try
{
if (command.ValidateCurrentState is not null)
command.ValidateCurrentState(await ReadAvailableStateAsync(command.EntityId, bounded.Token));
foreach (var rule in _safety) await rule.ValidateAsync(command, bounded.Token);
await _gateway.CallServiceAsync(command.Domain, command.Service, command.ToServiceData(), bounded.Token);
}
catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested && timeout.IsCancellationRequested)
{
throw new TimeoutException($"{command.Name}发送超时,未确认完成");
}
foreach (var rule in _safety) rule.Accepted(command, _time.GetUtcNow());
}
finally { _commandGate.Release(); }
}
public Task DelayAsync(TimeSpan delay, CancellationToken cancellationToken)
=> Task.Delay(delay, _time, cancellationToken);
public async Task RunAsync(IReadOnlyDictionary<string, HomeAssistantState> states,
List<HomeAssistantActionResult> results, DeviceCommand command, CancellationToken cancellationToken,
bool requireAvailable = true)
{
cancellationToken.ThrowIfCancellationRequested();
var action = new HomeAssistantActionResult { Name = command.Name, EntityId = command.EntityId };
results.Add(action);
if (string.IsNullOrWhiteSpace(command.EntityId))
{
action.Status = "Skipped"; action.Message = "实体未配置"; return;
}
if (requireAvailable && (!states.TryGetValue(command.EntityId, out var state) || !IsAvailable(state)))
{
action.Status = "Skipped"; action.Message = "实体不存在或当前离线"; return;
}
try
{
await SendAsync(command, cancellationToken);
action.Status = "Succeeded"; action.Message = "控制指令已发送";
}
catch (Exception exception) when (exception is not OperationCanceledException)
{
action.Status = "Failed"; action.Message = exception.Message;
}
}
public async Task VerifyParameterAsync(DeviceCommand command, CancellationToken cancellationToken)
{
var results = new List<HomeAssistantActionResult>
{
new() { Name = command.Name, EntityId = command.EntityId, Status = "Succeeded" }
};
await VerifyBatchAsync([command], results, cancellationToken);
if (results[0].Status != "Succeeded") throw new InvalidOperationException(results[0].Message);
}
public async Task VerifyBatchAsync(IReadOnlyList<DeviceCommand> commands,
List<HomeAssistantActionResult> results, CancellationToken cancellationToken)
{
var pending = commands.Where(command => !string.IsNullOrWhiteSpace(command.EntityId) &&
command.ExpectedState is not null).ToList();
for (var round = 0; round < _options.VerificationAttempts && pending.Count > 0; round++)
{
await DelayAsync(_options.VerificationDelay, cancellationToken);
IReadOnlyList<HomeAssistantState> states;
try { states = await _gateway.GetStatesAsync(cancellationToken); }
catch (Exception exception) when (exception is not OperationCanceledException)
{
foreach (var command in pending)
{
var action = FindResult(results, command);
action.Status = "Failed"; action.Message = $"无法回读确认:{exception.Message}";
}
return;
}
foreach (var command in pending.ToArray())
{
var action = FindResult(results, command);
var state = states.FirstOrDefault(item => string.Equals(item.EntityId, command.EntityId, StringComparison.OrdinalIgnoreCase));
var actual = state is null ? null : command.ExpectedAttribute is null
? state.State : state.GetStringAttribute(command.ExpectedAttribute);
if (state is not null && IsAvailable(state) && actual is not null && Matches(actual, command.ExpectedState!))
{
action.Status = "Succeeded"; action.Message = round == 0 ? "已回读确认目标状态" : "重试后已回读确认目标状态";
pending.Remove(command); continue;
}
if (round == _options.VerificationAttempts - 1)
{
action.Status = "Failed"; action.Message = $"未确认目标{(command.ExpectedAttribute is null ? "状态" : command.ExpectedAttribute)} {command.ExpectedState}(当前:{actual ?? "未读取到"})";
pending.Remove(command); continue;
}
if (!command.RetryOnVerificationMismatch) continue;
// 关闭属于尽力而为:缓存离线不能阻止发出重试,发送失败也不影响其他家具。
try { await SendAsync(command, cancellationToken); }
catch (Exception exception) when (exception is not OperationCanceledException)
{
action.Status = "Failed"; action.Message = $"重试失败:{exception.Message}";
}
}
}
}
private static HomeAssistantActionResult FindResult(List<HomeAssistantActionResult> results, DeviceCommand command)
=> results.Last(item => item.Name == command.Name && item.EntityId == command.EntityId);
private static bool Matches(string actual, string expected)
=> string.Equals(actual, expected, StringComparison.OrdinalIgnoreCase) ||
(decimal.TryParse(actual, NumberStyles.Number, CultureInfo.InvariantCulture, out var actualNumber) &&
decimal.TryParse(expected, NumberStyles.Number, CultureInfo.InvariantCulture, out var expectedNumber) &&
actualNumber == expectedNumber);
}
public sealed class DeviceExecutionOptions
{
public TimeSpan CommandTimeout { get; init; } = TimeSpan.FromSeconds(10);
public int VerificationAttempts { get; init; } = 3;
public TimeSpan VerificationDelay { get; init; } = TimeSpan.FromSeconds(2);
}
@@ -0,0 +1,49 @@
using Evergreen.Health.Application.Health.Devices.Contracts;
using Evergreen.Health.Application.Health.Integrations.HomeAssistant;
using Microsoft.Extensions.Options;
namespace Evergreen.Health.Application.Health.Scenes;
public interface IDeviceActionSafetyRule
{
Task ValidateAsync(DeviceCommand command, CancellationToken cancellationToken);
void Accepted(DeviceCommand command, DateTimeOffset now);
}
public sealed class SceneLightSafetyState
{
private readonly object _lock = new();
private DateTimeOffset? _offAt;
public DateTimeOffset? OffAt { get { lock (_lock) return _offAt; } }
public void MarkOff(DateTimeOffset now) { lock (_lock) _offAt = now; }
public void MarkOn() { lock (_lock) _offAt = null; }
}
/// <summary>只依赖“启动按摩”的动作意图,不知道床垫实体。防止灯灭后较晚的启动指令再开启按摩。</summary>
public sealed class LightMassageSafetyRule : IDeviceActionSafetyRule
{
private readonly IHomeAssistantGateway _gateway;
private readonly string _lightEntity;
private readonly SceneLightSafetyState _state;
public LightMassageSafetyRule(IHomeAssistantGateway gateway, IOptions<HomeAssistantOptions> options,
SceneLightSafetyState state) => (_gateway, _lightEntity, _state) = (gateway, options.Value.Entities.AmbientLight, state);
public async Task ValidateAsync(DeviceCommand command, CancellationToken cancellationToken)
{
if (command.Intent != DeviceCommandIntent.StartMassage || string.IsNullOrWhiteSpace(_lightEntity)) return;
var light = (await _gateway.GetStatesAsync(cancellationToken)).FirstOrDefault(item =>
string.Equals(item.EntityId, _lightEntity, StringComparison.OrdinalIgnoreCase));
var offAt = _state.OffAt;
if (light is null || !DeviceActionRunner.IsAvailable(light) ||
!string.Equals(light.State, "on", StringComparison.OrdinalIgnoreCase) ||
light.GetDoubleAttribute("brightness") is <= 0 ||
(offAt is not null && (light.LastUpdated is null || light.LastUpdated <= offAt)))
throw new InvalidOperationException("卧室灯光已关闭或状态未确认,不启动床垫按摩");
}
public void Accepted(DeviceCommand command, DateTimeOffset now)
{
if (command.Intent == DeviceCommandIntent.TurnRelaxationLightOff) _state.MarkOff(now);
else if (command.Intent == DeviceCommandIntent.TurnRelaxationLightOn) _state.MarkOn();
}
}
@@ -0,0 +1,34 @@
using Evergreen.Health.Application.Health.Devices.Contracts;
using Evergreen.Health.Application.Health.Integrations.HomeAssistant;
namespace Evergreen.Health.Application.Health.Scenes;
public sealed class DeviceSceneContext
{
public IReadOnlyDictionary<string, HomeAssistantState> States { get; }
public List<HomeAssistantActionResult> Results { get; }
public DeviceActionRunner Runner { get; }
public DeviceSceneContext(DeviceActionRunner runner, IReadOnlyDictionary<string, HomeAssistantState> states,
List<HomeAssistantActionResult> results) => (Runner, States, Results) = (runner, states, results);
public Task RunAsync(DeviceCommand command, CancellationToken cancellationToken)
=> Runner.RunAsync(States, Results, command, cancellationToken);
public async Task ExternalAsync(string name, string entityId, Func<CancellationToken, Task> operation,
CancellationToken cancellationToken)
{
cancellationToken.ThrowIfCancellationRequested();
var result = new HomeAssistantActionResult { Name = name, EntityId = entityId };
Results.Add(result);
try
{
await operation(cancellationToken);
result.Status = string.IsNullOrWhiteSpace(entityId) ? "Completed" : "Succeeded";
result.Message = string.IsNullOrWhiteSpace(entityId) ? "内部任务已处理" : "控制指令已发送";
}
catch (Exception exception) when (exception is not OperationCanceledException)
{
result.Status = "Failed"; result.Message = exception.Message;
}
}
}
@@ -0,0 +1,152 @@
using Evergreen.Health.Application.Health.Devices;
using Evergreen.Health.Application.Health.Integrations.HomeAssistant;
using Evergreen.Health.Application.Health.Integrations.MiAirX;
using Evergreen.Health.Application.Health.Models;
namespace Evergreen.Health.Application.Health.Scenes;
/// <summary>同一房间串行执行,后来的开启/关闭取消旧启动,避免延迟指令重新打开家具。</summary>
public sealed class HealthSceneCoordinator
{
private readonly DeviceModuleRegistry _modules;
private readonly DeviceActionRunner _runner;
private readonly IHomeAssistantGateway _gateway;
private readonly ILightTransitionRuntime _light;
private readonly ITcmMusicRuntime _music;
private readonly SemaphoreSlim _gate = new(1, 1);
private readonly object _stateLock = new();
private CancellationTokenSource? _active;
public HealthSceneCoordinator(DeviceModuleRegistry modules, DeviceActionRunner runner,
IHomeAssistantGateway gateway, ILightTransitionRuntime light, ITcmMusicRuntime music)
=> (_modules, _runner, _gateway, _light, _music) = (modules, runner, gateway, light, music);
public Task<HomeAssistantSceneOutput> ExecuteAsync(string sceneCode, PersonalizedSceneConfig? config,
string constitutionCode, CancellationToken cancellationToken)
=> ExecuteCoreAsync(sceneCode, config, constitutionCode, cancellationToken, null);
// 先登记本次操作,再读取档案/室温。否则较慢的旧准备请求可能在全关之后才开始开家具。
public Task<HomeAssistantSceneOutput> ExecutePreparedSleepAsync(
Func<CancellationToken, Task<PreparedSleepScene>> prepare, CancellationToken cancellationToken)
=> ExecuteCoreAsync("sleep", null, "", cancellationToken, prepare);
private async Task<HomeAssistantSceneOutput> ExecuteCoreAsync(string sceneCode, PersonalizedSceneConfig? config,
string constitutionCode, CancellationToken cancellationToken,
Func<CancellationToken, Task<PreparedSleepScene>>? prepare)
{
if (sceneCode is not ("sleep" or "all-off")) throw new InvalidOperationException("不支持的场景");
if (sceneCode == "sleep" && config is null && prepare is null) throw new InvalidOperationException("舒睡场景缺少体质参数");
CancellationTokenSource operation;
lock (_stateLock)
{
_active?.Cancel();
operation = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
_active = operation;
}
var results = new List<HomeAssistantActionResult>();
var entered = false;
var cancelled = false;
string? preparationFailureStatus = null;
try
{
await _gate.WaitAsync(operation.Token); entered = true;
if (prepare is not null)
{
var prepared = await prepare(operation.Token);
operation.Token.ThrowIfCancellationRequested();
config = prepared.Config; constitutionCode = prepared.ConstitutionCode;
}
var context = new DeviceSceneContext(_runner, new Dictionary<string, HomeAssistantState>(), results);
await context.ExternalAsync("停止旧灯光渐变任务", "",
async token => { await _light.CancelAsync(token); }, operation.Token);
await context.ExternalAsync("停止旧助眠音乐", _modules.Modules.FirstOrDefault(module => module.Key == "audio")?.PrimaryEntityId ?? "",
_music.StopMusicAsync, operation.Token);
IReadOnlyDictionary<string, HomeAssistantState> states;
try
{
states = (await _gateway.GetStatesAsync(operation.Token)).GroupBy(state => state.EntityId, StringComparer.OrdinalIgnoreCase)
.ToDictionary(group => group.Key, group => group.Last(), StringComparer.OrdinalIgnoreCase);
}
catch (Exception exception) when (exception is not OperationCanceledException)
{
// HA 不可达不能阻止 MiAirX 停止或尝试发送其他关闭指令。
results.Add(new() { Name = "读取设备状态", Status = "Failed", Message = exception.Message });
states = new Dictionary<string, HomeAssistantState>();
}
context = new DeviceSceneContext(_runner, states, results);
if (sceneCode == "sleep")
{
foreach (var module in _modules.Modules)
{
operation.Token.ThrowIfCancellationRequested();
try { await module.ExecuteSleepAsync(context, config!, constitutionCode, operation.Token); }
catch (Exception exception) when (exception is not OperationCanceledException)
{
results.Add(new() { Name = $"{module.DisplayName}舒睡设置", Status = "Failed", Message = exception.Message });
}
}
}
else
{
var shutdown = _modules.Modules.SelectMany(module => module.GetShutdownCommands()).ToArray();
foreach (var command in shutdown)
await _runner.RunAsync(states, results, command, operation.Token, requireAvailable: false);
await _runner.VerifyBatchAsync(shutdown, results, operation.Token);
}
}
catch (OperationCanceledException) when (operation.IsCancellationRequested)
{
cancelled = true;
if (entered)
{
var context = new DeviceSceneContext(_runner, new Dictionary<string, HomeAssistantState>(), results);
await context.ExternalAsync("取消灯光渐变", "", async token => { await _light.CancelAsync(token); }, CancellationToken.None);
await context.ExternalAsync("取消音乐渐弱", "", _music.StopMusicAsync, CancellationToken.None);
}
}
catch (Exception exception) when (exception is not OperationCanceledException)
{
preparationFailureStatus = exception is ScenePreparationException ? "AssessmentRequired" : "Failed";
results.Add(new() { Name = "准备或协调场景", Status = "Failed", Message = exception.Message });
}
finally
{
if (entered) _gate.Release();
lock (_stateLock)
{
if (ReferenceEquals(_active, operation)) _active = null;
operation.Dispose();
}
}
// 取消内部计时任务不能冒充家具执行成功。
var successCount = results.Count(result => result.Status == "Succeeded" && !string.IsNullOrWhiteSpace(result.EntityId));
var failed = results.Where(result => result.Status == "Failed").ToArray();
var skippedCount = results.Count(result => result.Status == "Skipped");
var status = preparationFailureStatus ?? (cancelled ? "Cancelled" : failed.Length == 0 && successCount > 0
? "Succeeded" : successCount > 0 ? "Partial" : "Failed");
var message = status switch
{
"Cancelled" => "当前场景已被更新的操作取消",
"AssessmentRequired" => failed.First().Message,
"Succeeded" => $"场景已执行:{successCount} 项成功,{skippedCount} 项未配置或离线而跳过",
"Partial" => $"场景部分完成:{failed.Length} 项失败,{skippedCount} 项跳过;未完成:{string.Join("、", failed.Select(result => result.Name).Distinct().Take(3))}",
_ => failed.Length == 0 ? "没有已执行的家具控制;设备未配置或当前不可用"
: $"场景未完成:{string.Join(";", failed.Select(result => result.Message).Take(3))}"
};
return new()
{
SceneCode = sceneCode, SceneName = sceneCode == "sleep" ? "舒睡模式" : "一键关闭",
Success = status is "Succeeded" or "Partial", Status = status, Message = message,
ClimateAdvice = sceneCode == "sleep" ? config?.ClimateAdvice : null,
Actions = results.Select(result => new HomeAssistantSceneActionOutput
{ Name = result.Name, EntityId = result.EntityId, Status = result.Status, Message = result.Message }).ToList()
};
}
}
public sealed record PreparedSleepScene(PersonalizedSceneConfig Config, string ConstitutionCode);
public sealed class ScenePreparationException : InvalidOperationException
{
public ScenePreparationException(string message) : base(message) { }
}
@@ -0,0 +1,25 @@
namespace Evergreen.Health.Application.Health.Scenes;
public interface ILightOffHandler
{
Task HandleAsync(CancellationToken cancellationToken);
}
/// <summary>跨设备联动由场景层分发;灯具不需要知道其他家具实体。</summary>
public sealed class LightOffDispatcher
{
private readonly IReadOnlyList<ILightOffHandler> _handlers;
public LightOffDispatcher(IEnumerable<ILightOffHandler> handlers) => _handlers = handlers.ToArray();
public async Task NotifyAsync(CancellationToken cancellationToken)
{
var failures = new List<Exception>();
foreach (var handler in _handlers)
{
cancellationToken.ThrowIfCancellationRequested();
try { await handler.HandleAsync(cancellationToken); }
catch (Exception exception) when (exception is not OperationCanceledException) { failures.Add(exception); }
}
if (failures.Count > 0)
throw new AggregateException("灯光已关闭,但部分安全联动未完成", failures);
}
}