Files
Evergreen-Health/Evergreen.Health.Application/Health/Scenes/DeviceActionRunner.cs
T
2026-10-09 16:46:30 +08:00

161 lines
8.5 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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);
}