162 lines
8.7 KiB
C#
162 lines
8.7 KiB
C#
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, int? verificationAttempts = null)
|
||
{
|
||
var results = new List<HomeAssistantActionResult>
|
||
{
|
||
new() { Name = command.Name, EntityId = command.EntityId, Status = "Succeeded" }
|
||
};
|
||
await VerifyBatchAsync([command], results, cancellationToken, verificationAttempts);
|
||
if (results[0].Status != "Succeeded") throw new InvalidOperationException(results[0].Message);
|
||
}
|
||
|
||
public async Task VerifyBatchAsync(IReadOnlyList<DeviceCommand> commands,
|
||
List<HomeAssistantActionResult> results, CancellationToken cancellationToken, int? verificationAttempts = null)
|
||
{
|
||
var attempts = verificationAttempts ?? _options.VerificationAttempts;
|
||
var pending = commands.Where(command => !string.IsNullOrWhiteSpace(command.EntityId) &&
|
||
command.ExpectedState is not null).ToList();
|
||
for (var round = 0; round < attempts && 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 == attempts - 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);
|
||
}
|